@otto-assistant/bridge 0.4.93 → 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.
@@ -430,15 +430,25 @@ function buildAuthorizeHandler(mode) {
430
430
  function toClaudeCodeToolName(name) {
431
431
  return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
432
432
  }
433
- function sanitizeSystemText(text) {
434
- return text.replaceAll(OPENCODE_IDENTITY, CLAUDE_CODE_IDENTITY);
433
+ function sanitizeSystemText(text, onError) {
434
+ const startIdx = text.indexOf(OPENCODE_IDENTITY);
435
+ if (startIdx === -1)
436
+ return text;
437
+ const codeRefsMarker = '# Code References';
438
+ const endIdx = text.indexOf(codeRefsMarker, startIdx);
439
+ if (endIdx === -1) {
440
+ onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`);
441
+ return text;
442
+ }
443
+ // Remove everything from the OpenCode identity up to (but not including) '# Code References'
444
+ return text.slice(0, startIdx) + text.slice(endIdx);
435
445
  }
436
- function prependClaudeCodeIdentity(system) {
446
+ function prependClaudeCodeIdentity(system, onError) {
437
447
  const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY };
438
448
  if (typeof system === 'undefined')
439
449
  return [identityBlock];
440
450
  if (typeof system === 'string') {
441
- const sanitized = sanitizeSystemText(system);
451
+ const sanitized = sanitizeSystemText(system, onError);
442
452
  if (sanitized === CLAUDE_CODE_IDENTITY)
443
453
  return [identityBlock];
444
454
  return [identityBlock, { type: 'text', text: sanitized }];
@@ -447,11 +457,11 @@ function prependClaudeCodeIdentity(system) {
447
457
  return [identityBlock, system];
448
458
  const sanitized = system.map((item) => {
449
459
  if (typeof item === 'string')
450
- return { type: 'text', text: sanitizeSystemText(item) };
460
+ return { type: 'text', text: sanitizeSystemText(item, onError) };
451
461
  if (item && typeof item === 'object' && item.type === 'text') {
452
462
  const text = item.text;
453
463
  if (typeof text === 'string') {
454
- return { ...item, text: sanitizeSystemText(text) };
464
+ return { ...item, text: sanitizeSystemText(text, onError) };
455
465
  }
456
466
  }
457
467
  return item;
@@ -465,7 +475,7 @@ function prependClaudeCodeIdentity(system) {
465
475
  }
466
476
  return [identityBlock, ...sanitized];
467
477
  }
468
- function rewriteRequestPayload(body) {
478
+ function rewriteRequestPayload(body, onError) {
469
479
  if (!body)
470
480
  return { body, modelId: undefined, reverseToolNameMap: new Map() };
471
481
  try {
@@ -486,7 +496,7 @@ function rewriteRequestPayload(body) {
486
496
  });
487
497
  }
488
498
  // Rename system prompt
489
- payload.system = prependClaudeCodeIdentity(payload.system);
499
+ payload.system = prependClaudeCodeIdentity(payload.system, onError);
490
500
  // Rename tool_choice
491
501
  if (payload.tool_choice &&
492
502
  typeof payload.tool_choice === 'object' &&
@@ -658,7 +668,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
658
668
  .text()
659
669
  .catch(() => undefined)
660
670
  : undefined;
661
- const rewritten = rewriteRequestPayload(originalBody);
671
+ const rewritten = rewriteRequestPayload(originalBody, (msg) => {
672
+ client.tui.showToast({
673
+ body: { message: msg, variant: 'error' },
674
+ }).catch(() => { });
675
+ });
662
676
  const headers = new Headers(init?.headers);
663
677
  if (input instanceof Request) {
664
678
  input.headers.forEach((v, k) => {
@@ -61,7 +61,7 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
61
61
  inject: true,
62
62
  text: `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
63
63
  `Current working directory: ${currentDir}. ` +
64
- `You MUST read, write, and edit files only under ${currentDir}. ` +
64
+ `You should read, write, and edit files under ${currentDir}. ` +
65
65
  `Do NOT read, write, or edit files under ${priorDirectory}.]`,
66
66
  };
67
67
  }
@@ -36,7 +36,7 @@ describe('shouldInjectPwd', () => {
36
36
  {
37
37
  "inject": true,
38
38
  "text": "
39
- [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
39
+ [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
40
40
  }
41
41
  `);
42
42
  });
@@ -50,7 +50,7 @@ describe('shouldInjectPwd', () => {
50
50
  {
51
51
  "inject": true,
52
52
  "text": "
53
- [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
53
+ [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
54
54
  }
55
55
  `);
56
56
  });
@@ -1,8 +1,11 @@
1
1
  // Discord-specific utility functions.
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
- import { ChannelType, GuildMember, MessageFlags, PermissionsBitField, } from 'discord.js';
5
- import { REST, Routes } from 'discord.js';
4
+ // Use namespace import for CJS interop discord.js is CJS and its named
5
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
6
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
7
+ import * as discord from 'discord.js';
8
+ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord;
6
9
  import { discordApiUrl } from './discord-urls.js';
7
10
  import { Lexer } from 'marked';
8
11
  import { splitTablesFromMarkdown } from './format-tables.js';
@@ -11,6 +11,7 @@
11
11
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
12
12
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
13
13
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
14
+ export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
14
15
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
15
16
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
16
17
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
package/dist/logger.js CHANGED
@@ -80,12 +80,18 @@ export function setLogFilePath(dataDir) {
80
80
  export function getLogFilePath() {
81
81
  return logFilePath;
82
82
  }
83
+ const MAX_LOG_ARG_LENGTH = 1000;
84
+ function truncate(str, max) {
85
+ if (str.length <= max)
86
+ return str;
87
+ return str.slice(0, max) + `… [truncated ${str.length - max} chars]`;
88
+ }
83
89
  function formatArg(arg) {
84
90
  if (typeof arg === 'string') {
85
- return sanitizeSensitiveText(arg, { redactPaths: false });
91
+ return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH);
86
92
  }
87
93
  const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
88
- return util.inspect(safeArg, { colors: true, depth: 4 });
94
+ return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH);
89
95
  }
90
96
  export function formatErrorWithStack(error) {
91
97
  if (error instanceof Error) {
@@ -40,6 +40,14 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
40
40
  const logger = createLogger(LogPrefix.SESSION);
41
41
  const discordLogger = createLogger(LogPrefix.DISCORD);
42
42
  const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
43
+ const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u;
44
+ function extractToastSessionId({ message }) {
45
+ const match = message.match(TOAST_SESSION_ID_REGEX);
46
+ return match?.[1];
47
+ }
48
+ function stripToastSessionId({ message }) {
49
+ return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd();
50
+ }
43
51
  const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
44
52
  process.env['KIMAKI_VITEST'] === '1';
45
53
  // ── Registry ─────────────────────────────────────────────────────
@@ -943,6 +951,9 @@ export class ThreadSessionRuntime {
943
951
  }
944
952
  const sessionId = this.state?.sessionId;
945
953
  const eventSessionId = getOpencodeEventSessionId(event);
954
+ const toastSessionId = event.type === 'tui.toast.show'
955
+ ? extractToastSessionId({ message: event.properties.message })
956
+ : undefined;
946
957
  if (shouldLogSessionEvents) {
947
958
  const eventDetails = (() => {
948
959
  if (event.type === 'session.error') {
@@ -970,6 +981,7 @@ export class ThreadSessionRuntime {
970
981
  logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
971
982
  }
972
983
  const isGlobalEvent = event.type === 'tui.toast.show';
984
+ const isScopedToastEvent = Boolean(toastSessionId);
973
985
  // Drop events that don't match current session (stale events from
974
986
  // previous sessions), unless it's a global event or a subtask session.
975
987
  if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
@@ -977,6 +989,11 @@ export class ThreadSessionRuntime {
977
989
  return; // stale event from previous session
978
990
  }
979
991
  }
992
+ if (isScopedToastEvent && toastSessionId !== sessionId) {
993
+ if (!this.getSubtaskInfoForSession(toastSessionId)) {
994
+ return;
995
+ }
996
+ }
980
997
  if (isOpencodeSessionEventLogEnabled()) {
981
998
  const eventLogResult = await appendOpencodeSessionEventLog({
982
999
  threadId: this.threadId,
@@ -2023,7 +2040,7 @@ export class ThreadSessionRuntime {
2023
2040
  if (properties.variant === 'warning') {
2024
2041
  return;
2025
2042
  }
2026
- const toastMessage = properties.message.trim();
2043
+ const toastMessage = stripToastSessionId({ message: properties.message }).trim();
2027
2044
  if (!toastMessage) {
2028
2045
  return;
2029
2046
  }
@@ -0,0 +1,251 @@
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
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createPatch, diffLines } from 'diff';
9
+ import * as errore from 'errore';
10
+ import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
11
+ import { initSentry, notifyError } from './sentry.js';
12
+ import { abbreviatePath } from './utils.js';
13
+ const logger = createPluginLogger('OPENCODE');
14
+ const TOAST_SESSION_MARKER_SEPARATOR = ' ';
15
+ function getSystemPromptDiffDir({ dataDir }) {
16
+ return path.join(dataDir, 'system-prompt-diffs');
17
+ }
18
+ function normalizeSystemPrompt({ system }) {
19
+ return system.join('\n');
20
+ }
21
+ function appendToastSessionMarker({ message, sessionId, }) {
22
+ return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
23
+ }
24
+ function buildTurnContext({ input, directory, }) {
25
+ const model = input.model
26
+ ? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
27
+ : undefined;
28
+ return {
29
+ agent: input.agent,
30
+ model,
31
+ directory,
32
+ };
33
+ }
34
+ function shouldSuppressDiffNotice({ previousContext, currentContext, }) {
35
+ if (!previousContext || !currentContext) {
36
+ return false;
37
+ }
38
+ return (previousContext.agent !== currentContext.agent
39
+ || previousContext.model !== currentContext.model
40
+ || previousContext.directory !== currentContext.directory);
41
+ }
42
+ function buildPatch({ beforeText, afterText, beforeLabel, afterLabel, }) {
43
+ const changes = diffLines(beforeText, afterText);
44
+ const additions = changes.reduce((count, change) => {
45
+ if (!change.added) {
46
+ return count;
47
+ }
48
+ return count + change.count;
49
+ }, 0);
50
+ const deletions = changes.reduce((count, change) => {
51
+ if (!change.removed) {
52
+ return count;
53
+ }
54
+ return count + change.count;
55
+ }, 0);
56
+ const patch = createPatch(afterLabel, beforeText, afterText, beforeLabel, afterLabel);
57
+ return {
58
+ additions,
59
+ deletions,
60
+ patch,
61
+ };
62
+ }
63
+ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterPrompt, }) {
64
+ const diff = buildPatch({
65
+ beforeText: beforePrompt,
66
+ afterText: afterPrompt,
67
+ beforeLabel: 'system-before.txt',
68
+ afterLabel: 'system-after.txt',
69
+ });
70
+ const timestamp = new Date().toISOString().replaceAll(':', '-');
71
+ const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
72
+ const filePath = path.join(sessionDir, `${timestamp}.diff`);
73
+ const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
74
+ const fileContent = [
75
+ `Session: ${sessionId}`,
76
+ `Created: ${new Date().toISOString()}`,
77
+ `Additions: ${diff.additions}`,
78
+ `Deletions: ${diff.deletions}`,
79
+ '',
80
+ diff.patch,
81
+ ].join('\n');
82
+ return errore.try({
83
+ try: () => {
84
+ fs.mkdirSync(sessionDir, { recursive: true });
85
+ fs.writeFileSync(filePath, fileContent);
86
+ // fs.writeFileSync(latestPromptPath, afterPrompt)
87
+ return {
88
+ additions: diff.additions,
89
+ deletions: diff.deletions,
90
+ filePath,
91
+ latestPromptPath,
92
+ };
93
+ },
94
+ catch: (error) => {
95
+ return new Error('Failed to write system prompt diff file', { cause: error });
96
+ },
97
+ });
98
+ }
99
+ function getOrCreateSessionState({ sessions, sessionId, }) {
100
+ const existing = sessions.get(sessionId);
101
+ if (existing) {
102
+ return existing;
103
+ }
104
+ const state = {
105
+ userTurnCount: 0,
106
+ previousTurnPrompt: undefined,
107
+ latestTurnPrompt: undefined,
108
+ latestTurnPromptTurn: 0,
109
+ comparedTurn: 0,
110
+ previousTurnContext: undefined,
111
+ currentTurnContext: undefined,
112
+ };
113
+ sessions.set(sessionId, state);
114
+ return state;
115
+ }
116
+ async function handleSystemTransform({ input, output, sessions, dataDir, client, }) {
117
+ const sessionId = input.sessionID;
118
+ if (!sessionId) {
119
+ return;
120
+ }
121
+ const currentPrompt = normalizeSystemPrompt({ system: output.system });
122
+ const state = getOrCreateSessionState({
123
+ sessions,
124
+ sessionId,
125
+ });
126
+ const currentTurn = state.userTurnCount;
127
+ state.latestTurnPrompt = currentPrompt;
128
+ state.latestTurnPromptTurn = currentTurn;
129
+ if (currentTurn <= 1) {
130
+ return;
131
+ }
132
+ if (state.comparedTurn === currentTurn) {
133
+ return;
134
+ }
135
+ const previousPrompt = state.previousTurnPrompt;
136
+ state.comparedTurn = currentTurn;
137
+ if (!previousPrompt || previousPrompt === currentPrompt) {
138
+ return;
139
+ }
140
+ if (shouldSuppressDiffNotice({
141
+ previousContext: state.previousTurnContext,
142
+ currentContext: state.currentTurnContext,
143
+ })) {
144
+ return;
145
+ }
146
+ if (!dataDir) {
147
+ return;
148
+ }
149
+ const diffFileResult = writeSystemPromptDiffFile({
150
+ dataDir,
151
+ sessionId,
152
+ beforePrompt: previousPrompt,
153
+ afterPrompt: currentPrompt,
154
+ });
155
+ if (diffFileResult instanceof Error) {
156
+ throw diffFileResult;
157
+ }
158
+ await client.tui.showToast({
159
+ body: {
160
+ variant: 'info',
161
+ title: 'Context cache discarded',
162
+ message: appendToastSessionMarker({
163
+ sessionId,
164
+ message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
165
+ `Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
166
+ `Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
167
+ }),
168
+ },
169
+ });
170
+ }
171
+ const systemPromptDriftPlugin = async ({ client, directory }) => {
172
+ initSentry();
173
+ const dataDir = process.env.KIMAKI_DATA_DIR;
174
+ if (dataDir) {
175
+ setPluginLogFilePath(dataDir);
176
+ }
177
+ const sessions = new Map();
178
+ return {
179
+ 'chat.message': async (input) => {
180
+ const sessionId = input.sessionID;
181
+ if (!sessionId) {
182
+ return;
183
+ }
184
+ const state = getOrCreateSessionState({ sessions, sessionId });
185
+ if (state.userTurnCount > 0
186
+ && state.latestTurnPromptTurn === state.userTurnCount) {
187
+ state.previousTurnPrompt = state.latestTurnPrompt;
188
+ state.previousTurnContext = state.currentTurnContext;
189
+ }
190
+ state.currentTurnContext = buildTurnContext({ input, directory });
191
+ state.userTurnCount += 1;
192
+ },
193
+ 'experimental.chat.system.transform': async (input, output) => {
194
+ const result = await errore.tryAsync({
195
+ try: async () => {
196
+ await handleSystemTransform({
197
+ input,
198
+ output,
199
+ sessions,
200
+ dataDir,
201
+ client,
202
+ });
203
+ },
204
+ catch: (error) => {
205
+ return new Error('system prompt drift transform hook failed', {
206
+ cause: error,
207
+ });
208
+ },
209
+ });
210
+ if (result instanceof Error) {
211
+ logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
212
+ void notifyError(result, 'system prompt drift plugin transform hook failed');
213
+ }
214
+ },
215
+ event: async ({ event }) => {
216
+ const result = await errore.tryAsync({
217
+ try: async () => {
218
+ if (event.type !== 'session.deleted') {
219
+ return;
220
+ }
221
+ const deletedSessionId = getDeletedSessionId({ event });
222
+ if (!deletedSessionId) {
223
+ return;
224
+ }
225
+ sessions.delete(deletedSessionId);
226
+ },
227
+ catch: (error) => {
228
+ return new Error('system prompt drift event hook failed', {
229
+ cause: error,
230
+ });
231
+ },
232
+ });
233
+ if (result instanceof Error) {
234
+ logger.warn(`[system-prompt-drift-plugin] ${formatPluginErrorWithStack(result)}`);
235
+ void notifyError(result, 'system prompt drift plugin event hook failed');
236
+ }
237
+ },
238
+ };
239
+ };
240
+ function getDeletedSessionId({ event }) {
241
+ if (event.type !== 'session.deleted') {
242
+ return undefined;
243
+ }
244
+ const sessionInfo = event.properties?.info;
245
+ if (!sessionInfo || typeof sessionInfo !== 'object') {
246
+ return undefined;
247
+ }
248
+ const id = 'id' in sessionInfo ? sessionInfo.id : undefined;
249
+ return typeof id === 'string' ? id : undefined;
250
+ }
251
+ export { systemPromptDriftPlugin };
package/dist/utils.js CHANGED
@@ -2,7 +2,11 @@
2
2
  // Includes Discord OAuth URL generation, array deduplication,
3
3
  // abort error detection, and date/time formatting helpers.
4
4
  import os from 'node:os';
5
- import { PermissionsBitField } from 'discord.js';
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
+ const { PermissionsBitField } = discord;
6
10
  import * as errore from 'errore';
7
11
  export function generateBotInstallUrl({ clientId, permissions = [
8
12
  PermissionsBitField.Flags.ViewChannel,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@otto-assistant/bridge",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.93",
5
+ "version": "0.4.96",
6
6
  "scripts": {
7
7
  "dev": "tsx src/bin.ts",
8
8
  "prepublishOnly": "pnpm generate && pnpm tsc",
@@ -66,6 +66,7 @@
66
66
  "@prisma/client": "7.4.2",
67
67
  "@purinton/resampler": "^1.0.4",
68
68
  "cron-parser": "^5.5.0",
69
+ "diff": "^8.0.4",
69
70
  "discord.js": "^14.25.1",
70
71
  "domhandler": "^6.0.1",
71
72
  "errore": "workspace:^",
@@ -528,17 +528,26 @@ function toClaudeCodeToolName(name: string) {
528
528
  return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
529
529
  }
530
530
 
531
- function sanitizeSystemText(text: string) {
532
- return text.replaceAll(OPENCODE_IDENTITY, CLAUDE_CODE_IDENTITY)
531
+ function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
532
+ const startIdx = text.indexOf(OPENCODE_IDENTITY)
533
+ if (startIdx === -1) return text
534
+ const codeRefsMarker = '# Code References'
535
+ const endIdx = text.indexOf(codeRefsMarker, startIdx)
536
+ if (endIdx === -1) {
537
+ onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`)
538
+ return text
539
+ }
540
+ // Remove everything from the OpenCode identity up to (but not including) '# Code References'
541
+ return text.slice(0, startIdx) + text.slice(endIdx)
533
542
  }
534
543
 
535
- function prependClaudeCodeIdentity(system: unknown) {
544
+ function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void) {
536
545
  const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY }
537
546
 
538
547
  if (typeof system === 'undefined') return [identityBlock]
539
548
 
540
549
  if (typeof system === 'string') {
541
- const sanitized = sanitizeSystemText(system)
550
+ const sanitized = sanitizeSystemText(system, onError)
542
551
  if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock]
543
552
  return [identityBlock, { type: 'text', text: sanitized }]
544
553
  }
@@ -546,11 +555,11 @@ function prependClaudeCodeIdentity(system: unknown) {
546
555
  if (!Array.isArray(system)) return [identityBlock, system]
547
556
 
548
557
  const sanitized = system.map((item) => {
549
- if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item) }
558
+ if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item, onError) }
550
559
  if (item && typeof item === 'object' && (item as { type?: unknown }).type === 'text') {
551
560
  const text = (item as { text?: unknown }).text
552
561
  if (typeof text === 'string') {
553
- return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) }
562
+ return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
554
563
  }
555
564
  }
556
565
  return item
@@ -568,7 +577,7 @@ function prependClaudeCodeIdentity(system: unknown) {
568
577
  return [identityBlock, ...sanitized]
569
578
  }
570
579
 
571
- function rewriteRequestPayload(body: string | undefined) {
580
+ function rewriteRequestPayload(body: string | undefined, onError?: (msg: string) => void) {
572
581
  if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() }
573
582
 
574
583
  try {
@@ -589,7 +598,7 @@ function rewriteRequestPayload(body: string | undefined) {
589
598
  }
590
599
 
591
600
  // Rename system prompt
592
- payload.system = prependClaudeCodeIdentity(payload.system)
601
+ payload.system = prependClaudeCodeIdentity(payload.system, onError)
593
602
 
594
603
  // Rename tool_choice
595
604
  if (
@@ -779,7 +788,11 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
779
788
  .catch(() => undefined)
780
789
  : undefined
781
790
 
782
- const rewritten = rewriteRequestPayload(originalBody)
791
+ const rewritten = rewriteRequestPayload(originalBody, (msg) => {
792
+ client.tui.showToast({
793
+ body: { message: msg, variant: 'error' },
794
+ }).catch(() => {})
795
+ })
783
796
  const headers = new Headers(init?.headers)
784
797
  if (input instanceof Request) {
785
798
  input.headers.forEach((v, k) => {
@@ -46,7 +46,7 @@ describe('shouldInjectPwd', () => {
46
46
  {
47
47
  "inject": true,
48
48
  "text": "
49
- [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You MUST read, write, and edit files only under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
49
+ [working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
50
50
  }
51
51
  `)
52
52
  })
@@ -62,7 +62,7 @@ describe('shouldInjectPwd', () => {
62
62
  {
63
63
  "inject": true,
64
64
  "text": "
65
- [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You MUST read, write, and edit files only under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
65
+ [working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
66
66
  }
67
67
  `)
68
68
  })
@@ -126,7 +126,7 @@ export function shouldInjectPwd({
126
126
  text:
127
127
  `\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
128
128
  `Current working directory: ${currentDir}. ` +
129
- `You MUST read, write, and edit files only under ${currentDir}. ` +
129
+ `You should read, write, and edit files under ${currentDir}. ` +
130
130
  `Do NOT read, write, or edit files under ${priorDirectory}.]`,
131
131
  }
132
132
  }
@@ -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
  }
@@ -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