@otto-assistant/bridge 0.4.93 → 0.4.97

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 (40) hide show
  1. package/dist/anthropic-account-identity.js +62 -0
  2. package/dist/anthropic-account-identity.test.js +38 -0
  3. package/dist/anthropic-auth-plugin.js +88 -16
  4. package/dist/anthropic-auth-state.js +28 -3
  5. package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
  6. package/dist/cli-parsing.test.js +12 -9
  7. package/dist/cli.js +23 -10
  8. package/dist/context-awareness-plugin.js +1 -1
  9. package/dist/context-awareness-plugin.test.js +2 -2
  10. package/dist/discord-command-registration.js +2 -2
  11. package/dist/discord-utils.js +5 -2
  12. package/dist/kimaki-opencode-plugin.js +1 -0
  13. package/dist/logger.js +8 -2
  14. package/dist/session-handler/thread-session-runtime.js +18 -1
  15. package/dist/system-message.js +1 -1
  16. package/dist/system-message.test.js +1 -1
  17. package/dist/system-prompt-drift-plugin.js +251 -0
  18. package/dist/utils.js +5 -1
  19. package/dist/worktrees.js +0 -33
  20. package/package.json +2 -1
  21. package/src/anthropic-account-identity.test.ts +52 -0
  22. package/src/anthropic-account-identity.ts +77 -0
  23. package/src/anthropic-auth-plugin.ts +97 -16
  24. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  25. package/src/anthropic-auth-state.ts +36 -3
  26. package/src/cli-parsing.test.ts +16 -9
  27. package/src/cli.ts +29 -11
  28. package/src/context-awareness-plugin.test.ts +2 -2
  29. package/src/context-awareness-plugin.ts +1 -1
  30. package/src/discord-command-registration.ts +2 -2
  31. package/src/discord-utils.ts +19 -17
  32. package/src/kimaki-opencode-plugin.ts +1 -0
  33. package/src/logger.ts +9 -2
  34. package/src/session-handler/thread-session-runtime.ts +21 -1
  35. package/src/system-message.test.ts +1 -1
  36. package/src/system-message.ts +1 -1
  37. package/src/system-prompt-drift-plugin.ts +379 -0
  38. package/src/utils.ts +5 -1
  39. package/src/worktrees.test.ts +1 -0
  40. package/src/worktrees.ts +1 -47
@@ -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
  }
@@ -224,7 +224,7 @@ describe('system-message', () => {
224
224
 
225
225
  This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
226
226
 
227
- By default, worktrees are created from \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
227
+ By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
228
228
 
229
229
  Critical recursion guard:
230
230
  - If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
@@ -559,7 +559,7 @@ kimaki send --channel ${channelId} --prompt "your task description" --worktree w
559
559
 
560
560
  This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
561
561
 
562
- By default, worktrees are created from \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
562
+ By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
563
563
 
564
564
  Critical recursion guard:
565
565
  - If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
@@ -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
 
@@ -220,4 +220,5 @@ describe('worktrees', () => {
220
220
  fs.rmSync(sandbox, { recursive: true, force: true })
221
221
  }
222
222
  })
223
+
223
224
  })
package/src/worktrees.ts CHANGED
@@ -527,52 +527,6 @@ type WorktreeResult = {
527
527
  async function resolveDefaultWorktreeTarget(
528
528
  directory: string,
529
529
  ): Promise<string> {
530
- const remoteHead = await execAsync(
531
- 'git symbolic-ref refs/remotes/origin/HEAD',
532
- {
533
- cwd: directory,
534
- },
535
- ).catch(() => {
536
- return null
537
- })
538
-
539
- const remoteRef = remoteHead?.stdout.trim()
540
- if (remoteRef?.startsWith('refs/remotes/')) {
541
- return remoteRef.replace('refs/remotes/', '')
542
- }
543
-
544
- const hasMain = await execAsync(
545
- 'git show-ref --verify --quiet refs/heads/main',
546
- {
547
- cwd: directory,
548
- },
549
- )
550
- .then(() => {
551
- return true
552
- })
553
- .catch(() => {
554
- return false
555
- })
556
- if (hasMain) {
557
- return 'main'
558
- }
559
-
560
- const hasMaster = await execAsync(
561
- 'git show-ref --verify --quiet refs/heads/master',
562
- {
563
- cwd: directory,
564
- },
565
- )
566
- .then(() => {
567
- return true
568
- })
569
- .catch(() => {
570
- return false
571
- })
572
- if (hasMaster) {
573
- return 'master'
574
- }
575
-
576
530
  return 'HEAD'
577
531
  }
578
532
 
@@ -608,7 +562,7 @@ export async function createWorktreeWithSubmodules({
608
562
  }: {
609
563
  directory: string
610
564
  name: string
611
- /** Override the base branch to create the worktree from. Defaults to origin/HEAD → main → master → HEAD. */
565
+ /** Override the base branch to create the worktree from. Defaults to HEAD. */
612
566
  baseBranch?: string
613
567
  /** Called with a short phase label so callers can update UI (e.g. Discord status message). */
614
568
  onProgress?: (phase: string) => void