@otto-assistant/bridge 0.4.92 → 0.4.96

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 (36) hide show
  1. package/dist/agent-model.e2e.test.js +2 -1
  2. package/dist/anthropic-auth-plugin.js +30 -9
  3. package/dist/anthropic-auth-plugin.test.js +7 -1
  4. package/dist/anthropic-auth-state.js +17 -1
  5. package/dist/cli.js +2 -3
  6. package/dist/commands/agent.js +2 -2
  7. package/dist/commands/merge-worktree.js +11 -8
  8. package/dist/context-awareness-plugin.js +1 -1
  9. package/dist/context-awareness-plugin.test.js +2 -2
  10. package/dist/discord-utils.js +5 -2
  11. package/dist/kimaki-opencode-plugin.js +1 -0
  12. package/dist/logger.js +8 -2
  13. package/dist/session-handler/thread-session-runtime.js +20 -3
  14. package/dist/system-message.js +13 -0
  15. package/dist/system-message.test.js +13 -0
  16. package/dist/system-prompt-drift-plugin.js +251 -0
  17. package/dist/utils.js +5 -1
  18. package/package.json +2 -1
  19. package/skills/npm-package/SKILL.md +14 -9
  20. package/src/agent-model.e2e.test.ts +2 -1
  21. package/src/anthropic-auth-plugin.test.ts +7 -1
  22. package/src/anthropic-auth-plugin.ts +29 -9
  23. package/src/anthropic-auth-state.ts +28 -2
  24. package/src/cli.ts +2 -2
  25. package/src/commands/agent.ts +2 -2
  26. package/src/commands/merge-worktree.ts +11 -8
  27. package/src/context-awareness-plugin.test.ts +2 -2
  28. package/src/context-awareness-plugin.ts +1 -1
  29. package/src/discord-utils.ts +19 -17
  30. package/src/kimaki-opencode-plugin.ts +1 -0
  31. package/src/logger.ts +9 -2
  32. package/src/session-handler/thread-session-runtime.ts +23 -3
  33. package/src/system-message.test.ts +13 -0
  34. package/src/system-message.ts +13 -0
  35. package/src/system-prompt-drift-plugin.ts +379 -0
  36. package/src/utils.ts +5 -1
@@ -2,19 +2,21 @@
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
4
 
5
- import {
6
- type APIInteractionGuildMember,
7
- type AutocompleteInteraction,
8
- ChannelType,
9
- GuildMember,
10
- MessageFlags,
11
- PermissionsBitField,
12
- type Guild,
13
- type Message,
14
- type TextChannel,
15
- type ThreadChannel,
5
+ // Use namespace import for CJS interop — discord.js is CJS and its named
6
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
7
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
8
+ import * as discord from 'discord.js'
9
+ import type {
10
+ APIInteractionGuildMember,
11
+ AutocompleteInteraction,
12
+ GuildMember as GuildMemberType,
13
+ Guild,
14
+ Message,
15
+ REST as RESTType,
16
+ TextChannel,
17
+ ThreadChannel,
16
18
  } from 'discord.js'
17
- import { REST, Routes } from 'discord.js'
19
+ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
18
20
  import type { OpencodeClient } from '@opencode-ai/sdk/v2'
19
21
  import { discordApiUrl } from './discord-urls.js'
20
22
  import { Lexer } from 'marked'
@@ -37,7 +39,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
37
39
  * Returns false if member is null or has the "no-kimaki" role (overrides all).
38
40
  */
39
41
  export function hasKimakiBotPermission(
40
- member: GuildMember | APIInteractionGuildMember | null,
42
+ member: GuildMemberType | APIInteractionGuildMember | null,
41
43
  guild?: Guild | null,
42
44
  ): boolean {
43
45
  if (!member) {
@@ -61,7 +63,7 @@ export function hasKimakiBotPermission(
61
63
  }
62
64
 
63
65
  function hasRoleByName(
64
- member: GuildMember | APIInteractionGuildMember,
66
+ member: GuildMemberType | APIInteractionGuildMember,
65
67
  roleName: string,
66
68
  guild?: Guild | null,
67
69
  ): boolean {
@@ -89,7 +91,7 @@ function hasRoleByName(
89
91
  * Check if the member has the "no-kimaki" role that blocks bot access.
90
92
  * Separate from hasKimakiBotPermission so callers can show a specific error message.
91
93
  */
92
- export function hasNoKimakiRole(member: GuildMember | null): boolean {
94
+ export function hasNoKimakiRole(member: GuildMemberType | null): boolean {
93
95
  if (!member?.roles?.cache) {
94
96
  return false
95
97
  }
@@ -108,7 +110,7 @@ export async function reactToThread({
108
110
  channelId,
109
111
  emoji,
110
112
  }: {
111
- rest: REST
113
+ rest: RESTType
112
114
  threadId: string
113
115
  /** Parent channel ID where the thread starter message lives.
114
116
  * If not provided, fetches the thread info from Discord API to resolve it. */
@@ -169,7 +171,7 @@ export async function archiveThread({
169
171
  client,
170
172
  archiveDelay = 0,
171
173
  }: {
172
- rest: REST
174
+ rest: RESTType
173
175
  threadId: string
174
176
  parentChannelId?: string
175
177
  sessionId?: string
@@ -12,6 +12,7 @@
12
12
  export { ipcToolsPlugin } from './ipc-tools-plugin.js'
13
13
  export { contextAwarenessPlugin } from './context-awareness-plugin.js'
14
14
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
15
+ export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
15
16
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
16
17
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
17
18
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
package/src/logger.ts CHANGED
@@ -95,12 +95,19 @@ export function getLogFilePath(): string | null {
95
95
  return logFilePath
96
96
  }
97
97
 
98
+ const MAX_LOG_ARG_LENGTH = 1000
99
+
100
+ function truncate(str: string, max: number): string {
101
+ if (str.length <= max) return str
102
+ return str.slice(0, max) + `… [truncated ${str.length - max} chars]`
103
+ }
104
+
98
105
  function formatArg(arg: unknown): string {
99
106
  if (typeof arg === 'string') {
100
- return sanitizeSensitiveText(arg, { redactPaths: false })
107
+ return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH)
101
108
  }
102
109
  const safeArg = sanitizeUnknownValue(arg, { redactPaths: false })
103
- return util.inspect(safeArg, { colors: true, depth: 4 })
110
+ return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH)
104
111
  }
105
112
 
106
113
  export function formatErrorWithStack(error: unknown): string {
@@ -137,6 +137,17 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
137
137
  const logger = createLogger(LogPrefix.SESSION)
138
138
  const discordLogger = createLogger(LogPrefix.DISCORD)
139
139
  const DETERMINISTIC_CONTEXT_LIMIT = 100_000
140
+ const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u
141
+
142
+ function extractToastSessionId({ message }: { message: string }): string | undefined {
143
+ const match = message.match(TOAST_SESSION_ID_REGEX)
144
+ return match?.[1]
145
+ }
146
+
147
+ function stripToastSessionId({ message }: { message: string }): string {
148
+ return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd()
149
+ }
150
+
140
151
  const shouldLogSessionEvents =
141
152
  process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
142
153
  process.env['KIMAKI_VITEST'] === '1'
@@ -1381,6 +1392,9 @@ export class ThreadSessionRuntime {
1381
1392
  const sessionId = this.state?.sessionId
1382
1393
 
1383
1394
  const eventSessionId = getOpencodeEventSessionId(event)
1395
+ const toastSessionId = event.type === 'tui.toast.show'
1396
+ ? extractToastSessionId({ message: event.properties.message })
1397
+ : undefined
1384
1398
 
1385
1399
  if (shouldLogSessionEvents) {
1386
1400
  const eventDetails = (() => {
@@ -1412,6 +1426,7 @@ export class ThreadSessionRuntime {
1412
1426
  }
1413
1427
 
1414
1428
  const isGlobalEvent = event.type === 'tui.toast.show'
1429
+ const isScopedToastEvent = Boolean(toastSessionId)
1415
1430
 
1416
1431
  // Drop events that don't match current session (stale events from
1417
1432
  // previous sessions), unless it's a global event or a subtask session.
@@ -1420,6 +1435,11 @@ export class ThreadSessionRuntime {
1420
1435
  return // stale event from previous session
1421
1436
  }
1422
1437
  }
1438
+ if (isScopedToastEvent && toastSessionId !== sessionId) {
1439
+ if (!this.getSubtaskInfoForSession(toastSessionId!)) {
1440
+ return
1441
+ }
1442
+ }
1423
1443
 
1424
1444
  if (isOpencodeSessionEventLogEnabled()) {
1425
1445
  const eventLogResult = await appendOpencodeSessionEventLog({
@@ -2763,7 +2783,7 @@ export class ThreadSessionRuntime {
2763
2783
  if (properties.variant === 'warning') {
2764
2784
  return
2765
2785
  }
2766
- const toastMessage = properties.message.trim()
2786
+ const toastMessage = stripToastSessionId({ message: properties.message }).trim()
2767
2787
  if (!toastMessage) {
2768
2788
  return
2769
2789
  }
@@ -4111,8 +4131,8 @@ export class ThreadSessionRuntime {
4111
4131
  const truncate = (s: string, max: number) => {
4112
4132
  return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
4113
4133
  }
4114
- const truncatedFolder = truncate(folderName, 15)
4115
- const truncatedBranch = truncate(branchName, 15)
4134
+ const truncatedFolder = truncate(folderName, 30)
4135
+ const truncatedBranch = truncate(branchName, 30)
4116
4136
  const projectInfo = truncatedBranch
4117
4137
  ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
4118
4138
  : `${truncatedFolder} ⋅ `
@@ -142,10 +142,23 @@ describe('system-message', () => {
142
142
  - \`plan\`: planning only
143
143
  - \`build\`: edits files
144
144
 
145
+ ## running opencode commands via kimaki send
146
+
147
+ You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
148
+
149
+ kimaki send --thread <thread_id> --prompt "/review fix the auth module"
150
+ kimaki send --channel chan_123 --prompt "/build-cmd update dependencies" --user "Tommy"
151
+
152
+ The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
153
+
145
154
  ## switching agents in the current session
146
155
 
147
156
  The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
148
157
 
158
+ You can also switch agents via \`kimaki send\`:
159
+
160
+ kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
161
+
149
162
  ## scheduled sends and task management
150
163
 
151
164
  Use \`--send-at\` to schedule a one-time or recurring task:
@@ -477,10 +477,23 @@ Use --agent to specify which agent to use for the session:
477
477
  kimaki send --channel ${channelId} --prompt "Plan the refactor of the auth module" --agent plan${userArg}
478
478
  ${availableAgentsContext}
479
479
 
480
+ ## running opencode commands via kimaki send
481
+
482
+ You can trigger registered opencode commands (slash commands, skills, MCP prompts) by starting the \`--prompt\` with \`/commandname\`:
483
+
484
+ kimaki send --thread <thread_id> --prompt "/review fix the auth module"
485
+ kimaki send --channel ${channelId} --prompt "/build-cmd update dependencies"${userArg}
486
+
487
+ The command name must match a registered opencode command. If the command is not recognized, the prompt is sent as plain text to the model. This works for both new threads (\`--channel\`) and existing threads (\`--thread\`/\`--session\`).
488
+
480
489
  ## switching agents in the current session
481
490
 
482
491
  The user can switch the active agent mid-session using the Discord slash command \`/<agentname>-agent\`. For example if you are in plan mode and the user asks you to edit files, tell them to run \`/build-agent\` to switch to the build agent first.
483
492
 
493
+ You can also switch agents via \`kimaki send\`:
494
+
495
+ kimaki send --thread <thread_id> --prompt "/<agentname>-agent"
496
+
484
497
  ## scheduled sends and task management
485
498
 
486
499
  Use \`--send-at\` to schedule a one-time or recurring task:
@@ -0,0 +1,379 @@
1
+ // OpenCode plugin that detects per-session system prompt drift across turns.
2
+ // When the effective system prompt changes after the first user message, it
3
+ // writes a debug diff file and shows a toast because prompt-cache invalidation
4
+ // increases rate-limit usage and usually means another plugin is mutating the
5
+ // system prompt unexpectedly.
6
+
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import type { Plugin } from '@opencode-ai/plugin'
10
+ import { createPatch, diffLines } from 'diff'
11
+ import * as errore from 'errore'
12
+ import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js'
13
+ import { initSentry, notifyError } from './sentry.js'
14
+ import { abbreviatePath } from './utils.js'
15
+
16
+ const logger = createPluginLogger('OPENCODE')
17
+ const TOAST_SESSION_MARKER_SEPARATOR = ' '
18
+
19
+ type PluginHooks = Awaited<ReturnType<Plugin>>
20
+ type SystemTransformHook = NonNullable<PluginHooks['experimental.chat.system.transform']>
21
+ type SystemTransformInput = Parameters<SystemTransformHook>[0]
22
+ type SystemTransformOutput = Parameters<SystemTransformHook>[1]
23
+ type PluginEventHook = NonNullable<PluginHooks['event']>
24
+ type PluginEvent = Parameters<PluginEventHook>[0]['event']
25
+ type ChatMessageHook = NonNullable<PluginHooks['chat.message']>
26
+ type ChatMessageInput = Parameters<ChatMessageHook>[0]
27
+
28
+ type SessionState = {
29
+ userTurnCount: number
30
+ previousTurnPrompt: string | undefined
31
+ latestTurnPrompt: string | undefined
32
+ latestTurnPromptTurn: number
33
+ comparedTurn: number
34
+ previousTurnContext: TurnContext | undefined
35
+ currentTurnContext: TurnContext | undefined
36
+ }
37
+
38
+ type SystemPromptDiff = {
39
+ additions: number
40
+ deletions: number
41
+ patch: string
42
+ }
43
+
44
+ type TurnContext = {
45
+ agent: string | undefined
46
+ model: string | undefined
47
+ directory: string
48
+ }
49
+
50
+ function getSystemPromptDiffDir({ dataDir }: { dataDir: string }): string {
51
+ return path.join(dataDir, 'system-prompt-diffs')
52
+ }
53
+
54
+ function normalizeSystemPrompt({ system }: { system: string[] }): string {
55
+ return system.join('\n')
56
+ }
57
+
58
+ function appendToastSessionMarker({
59
+ message,
60
+ sessionId,
61
+ }: {
62
+ message: string
63
+ sessionId: string
64
+ }): string {
65
+ return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`
66
+ }
67
+
68
+ function buildTurnContext({
69
+ input,
70
+ directory,
71
+ }: {
72
+ input: ChatMessageInput
73
+ directory: string
74
+ }): TurnContext {
75
+ const model = input.model
76
+ ? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
77
+ : undefined
78
+ return {
79
+ agent: input.agent,
80
+ model,
81
+ directory,
82
+ }
83
+ }
84
+
85
+ function shouldSuppressDiffNotice({
86
+ previousContext,
87
+ currentContext,
88
+ }: {
89
+ previousContext: TurnContext | undefined
90
+ currentContext: TurnContext | undefined
91
+ }): boolean {
92
+ if (!previousContext || !currentContext) {
93
+ return false
94
+ }
95
+ return (
96
+ previousContext.agent !== currentContext.agent
97
+ || previousContext.model !== currentContext.model
98
+ || previousContext.directory !== currentContext.directory
99
+ )
100
+ }
101
+
102
+ function buildPatch({
103
+ beforeText,
104
+ afterText,
105
+ beforeLabel,
106
+ afterLabel,
107
+ }: {
108
+ beforeText: string
109
+ afterText: string
110
+ beforeLabel: string
111
+ afterLabel: string
112
+ }): SystemPromptDiff {
113
+ const changes = diffLines(beforeText, afterText)
114
+ const additions = changes.reduce((count, change) => {
115
+ if (!change.added) {
116
+ return count
117
+ }
118
+ return count + change.count
119
+ }, 0)
120
+ const deletions = changes.reduce((count, change) => {
121
+ if (!change.removed) {
122
+ return count
123
+ }
124
+ return count + change.count
125
+ }, 0)
126
+ const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel)
127
+
128
+ return {
129
+ additions,
130
+ deletions,
131
+ patch,
132
+ }
133
+ }
134
+
135
+ function writeSystemPromptDiffFile({
136
+ dataDir,
137
+ sessionId,
138
+ beforePrompt,
139
+ afterPrompt,
140
+ }: {
141
+ dataDir: string
142
+ sessionId: string
143
+ beforePrompt: string
144
+ afterPrompt: string
145
+ }): Error | {
146
+ additions: number
147
+ deletions: number
148
+ filePath: string
149
+ latestPromptPath: string
150
+ } {
151
+ const diff = buildPatch({
152
+ beforeText: beforePrompt,
153
+ afterText: afterPrompt,
154
+ beforeLabel: 'system-before.txt',
155
+ afterLabel: 'system-after.txt',
156
+ })
157
+ const timestamp = new Date().toISOString().replaceAll(':', '-')
158
+ const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId)
159
+ const filePath = path.join(sessionDir, `${timestamp}.diff`)
160
+ const latestPromptPath = path.join(sessionDir, `${timestamp}.md`)
161
+ const fileContent = [
162
+ `Session: ${sessionId}`,
163
+ `Created: ${new Date().toISOString()}`,
164
+ `Additions: ${diff.additions}`,
165
+ `Deletions: ${diff.deletions}`,
166
+ '',
167
+ diff.patch,
168
+ ].join('\n')
169
+
170
+ return errore.try({
171
+ try: () => {
172
+ fs.mkdirSync(sessionDir, { recursive: true })
173
+ fs.writeFileSync(filePath, fileContent)
174
+ // fs.writeFileSync(latestPromptPath, afterPrompt)
175
+ return {
176
+ additions: diff.additions,
177
+ deletions: diff.deletions,
178
+ filePath,
179
+ latestPromptPath,
180
+ }
181
+ },
182
+ catch: (error) => {
183
+ return new Error('Failed to write system prompt diff file', { cause: error })
184
+ },
185
+ })
186
+ }
187
+
188
+ function getOrCreateSessionState({
189
+ sessions,
190
+ sessionId,
191
+ }: {
192
+ sessions: Map<string, SessionState>
193
+ sessionId: string
194
+ }): SessionState {
195
+ const existing = sessions.get(sessionId)
196
+ if (existing) {
197
+ return existing
198
+ }
199
+ const state = {
200
+ userTurnCount: 0,
201
+ previousTurnPrompt: undefined,
202
+ latestTurnPrompt: undefined,
203
+ latestTurnPromptTurn: 0,
204
+ comparedTurn: 0,
205
+ previousTurnContext: undefined,
206
+ currentTurnContext: undefined,
207
+ }
208
+ sessions.set(sessionId, state)
209
+ return state
210
+ }
211
+
212
+ async function handleSystemTransform({
213
+ input,
214
+ output,
215
+ sessions,
216
+ dataDir,
217
+ client,
218
+ }: {
219
+ input: SystemTransformInput
220
+ output: SystemTransformOutput
221
+ sessions: Map<string, SessionState>
222
+ dataDir: string | undefined
223
+ client: Parameters<Plugin>[0]['client']
224
+ }): Promise<void> {
225
+ const sessionId = input.sessionID
226
+ if (!sessionId) {
227
+ return
228
+ }
229
+
230
+ const currentPrompt = normalizeSystemPrompt({ system: output.system })
231
+ const state = getOrCreateSessionState({
232
+ sessions,
233
+ sessionId,
234
+ })
235
+ const currentTurn = state.userTurnCount
236
+ state.latestTurnPrompt = currentPrompt
237
+ state.latestTurnPromptTurn = currentTurn
238
+
239
+ if (currentTurn <= 1) {
240
+ return
241
+ }
242
+ if (state.comparedTurn === currentTurn) {
243
+ return
244
+ }
245
+ const previousPrompt = state.previousTurnPrompt
246
+ state.comparedTurn = currentTurn
247
+ if (!previousPrompt || previousPrompt === currentPrompt) {
248
+ return
249
+ }
250
+ if (
251
+ shouldSuppressDiffNotice({
252
+ previousContext: state.previousTurnContext,
253
+ currentContext: state.currentTurnContext,
254
+ })
255
+ ) {
256
+ return
257
+ }
258
+
259
+ if (!dataDir) {
260
+ return
261
+ }
262
+
263
+ const diffFileResult = writeSystemPromptDiffFile({
264
+ dataDir,
265
+ sessionId,
266
+ beforePrompt: previousPrompt,
267
+ afterPrompt: currentPrompt,
268
+ })
269
+ if (diffFileResult instanceof Error) {
270
+ throw diffFileResult
271
+ }
272
+
273
+ await client.tui.showToast({
274
+ body: {
275
+ variant: 'info',
276
+ title: 'Context cache discarded',
277
+ message: appendToastSessionMarker({
278
+ sessionId,
279
+ message:
280
+ `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
281
+ `Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
282
+ `Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
283
+ }),
284
+ },
285
+ })
286
+ }
287
+
288
+ const systemPromptDriftPlugin: Plugin = async ({ client, directory }) => {
289
+ initSentry()
290
+
291
+ const dataDir = process.env.KIMAKI_DATA_DIR
292
+ if (dataDir) {
293
+ setPluginLogFilePath(dataDir)
294
+ }
295
+
296
+ const sessions = new Map<string, SessionState>()
297
+
298
+ return {
299
+ 'chat.message': async (input) => {
300
+ const sessionId = input.sessionID
301
+ if (!sessionId) {
302
+ return
303
+ }
304
+ const state = getOrCreateSessionState({ sessions, sessionId })
305
+ if (
306
+ state.userTurnCount > 0
307
+ && state.latestTurnPromptTurn === state.userTurnCount
308
+ ) {
309
+ state.previousTurnPrompt = state.latestTurnPrompt
310
+ state.previousTurnContext = state.currentTurnContext
311
+ }
312
+ state.currentTurnContext = buildTurnContext({ input, directory })
313
+ state.userTurnCount += 1
314
+ },
315
+ 'experimental.chat.system.transform': async (input, output) => {
316
+ const result = await errore.tryAsync({
317
+ try: async () => {
318
+ await handleSystemTransform({
319
+ input,
320
+ output,
321
+ sessions,
322
+ dataDir,
323
+ client,
324
+ })
325
+ },
326
+ catch: (error) => {
327
+ return new Error('system prompt drift transform hook failed', {
328
+ cause: error,
329
+ })
330
+ },
331
+ })
332
+ if (result instanceof Error) {
333
+ logger.warn(
334
+ `[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
335
+ )
336
+ void notifyError(result, 'system prompt drift plugin transform hook failed')
337
+ }
338
+ },
339
+ event: async ({ event }) => {
340
+ const result = await errore.tryAsync({
341
+ try: async () => {
342
+ if (event.type !== 'session.deleted') {
343
+ return
344
+ }
345
+ const deletedSessionId = getDeletedSessionId({ event })
346
+ if (!deletedSessionId) {
347
+ return
348
+ }
349
+ sessions.delete(deletedSessionId)
350
+ },
351
+ catch: (error) => {
352
+ return new Error('system prompt drift event hook failed', {
353
+ cause: error,
354
+ })
355
+ },
356
+ })
357
+ if (result instanceof Error) {
358
+ logger.warn(
359
+ `[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`,
360
+ )
361
+ void notifyError(result, 'system prompt drift plugin event hook failed')
362
+ }
363
+ },
364
+ }
365
+ }
366
+
367
+ function getDeletedSessionId({ event }: { event: PluginEvent }): string | undefined {
368
+ if (event.type !== 'session.deleted') {
369
+ return undefined
370
+ }
371
+ const sessionInfo = event.properties?.info
372
+ if (!sessionInfo || typeof sessionInfo !== 'object') {
373
+ return undefined
374
+ }
375
+ const id = 'id' in sessionInfo ? sessionInfo.id : undefined
376
+ return typeof id === 'string' ? id : undefined
377
+ }
378
+
379
+ export { systemPromptDriftPlugin }
package/src/utils.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  // abort error detection, and date/time formatting helpers.
4
4
 
5
5
  import os from 'node:os'
6
- import { PermissionsBitField } from 'discord.js'
6
+ // Use namespace import for CJS interop discord.js is CJS and its named
7
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
8
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
9
+ import * as discord from 'discord.js'
10
+ const { PermissionsBitField } = discord
7
11
  import type { BotMode } from './database.js'
8
12
  import * as errore from 'errore'
9
13