@otto-assistant/bridge 0.4.100 → 0.4.102

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.
@@ -129,6 +129,8 @@ const PROVIDER_POPULARITY_ORDER: string[] = [
129
129
  'xai',
130
130
  'groq',
131
131
  'deepseek',
132
+ 'opencode',
133
+ 'opencode-go',
132
134
  'mistral',
133
135
  'openrouter',
134
136
  'fireworks-ai',
@@ -137,12 +139,12 @@ const PROVIDER_POPULARITY_ORDER: string[] = [
137
139
  'azure',
138
140
  'google-vertex',
139
141
  'google-vertex-anthropic',
140
- 'cohere',
142
+ // 'cohere',
141
143
  'cerebras',
142
- 'perplexity',
144
+ // 'perplexity',
143
145
  'cloudflare-workers-ai',
144
- 'novita-ai',
145
- 'huggingface',
146
+ // 'novita-ai',
147
+ // 'huggingface',
146
148
  'deepinfra',
147
149
  'github-models',
148
150
  'lmstudio',
@@ -1,7 +1,6 @@
1
1
  // OpenCode plugin that injects synthetic message parts for context awareness:
2
2
  // - Git branch / detached HEAD changes
3
3
  // - Working directory (pwd) changes (e.g. after /new-worktree mid-session)
4
- // - MEMORY.md table of contents on first message
5
4
  // - MEMORY.md reminder after a large assistant reply
6
5
  // - Onboarding tutorial instructions (when TUTORIAL_WELCOME_TEXT detected)
7
6
  //
@@ -18,8 +17,6 @@
18
17
 
19
18
  import type { Plugin } from '@opencode-ai/plugin'
20
19
  import crypto from 'node:crypto'
21
- import fs from 'node:fs'
22
- import path from 'node:path'
23
20
  import * as errore from 'errore'
24
21
  import {
25
22
  createPluginLogger,
@@ -29,7 +26,6 @@ import {
29
26
  import { setDataDir } from './config.js'
30
27
  import { initSentry, notifyError } from './sentry.js'
31
28
  import { execAsync } from './exec-async.js'
32
- import { condenseMemoryMd } from './condense-memory.js'
33
29
  import {
34
30
  ONBOARDING_TUTORIAL_INSTRUCTIONS,
35
31
  TUTORIAL_WELCOME_TEXT,
@@ -49,7 +45,6 @@ type GitState = {
49
45
  // All per-session mutable state in one place. One Map entry, one delete.
50
46
  type SessionState = {
51
47
  gitState: GitState | undefined
52
- memoryInjected: boolean
53
48
  lastMemoryReminderAssistantMessageId: string | undefined
54
49
  tutorialInjected: boolean
55
50
  // Last directory observed via session.get(). Refreshed on each real user
@@ -60,17 +55,6 @@ type SessionState = {
60
55
  announcedDirectory: string | undefined
61
56
  }
62
57
 
63
- function createSessionState(): SessionState {
64
- return {
65
- gitState: undefined,
66
- memoryInjected: false,
67
- lastMemoryReminderAssistantMessageId: undefined,
68
- tutorialInjected: false,
69
- resolvedDirectory: undefined,
70
- announcedDirectory: undefined,
71
- }
72
- }
73
-
74
58
  // Minimal type for the opencode plugin client (v1 SDK style with path objects).
75
59
  type PluginClient = {
76
60
  session: {
@@ -147,10 +131,6 @@ type AssistantMessageInfo = {
147
131
  tokens?: AssistantTokenUsage
148
132
  }
149
133
 
150
- function getOutputTokenTotal(tokens: AssistantTokenUsage): number {
151
- return Math.max(0, tokens.output + tokens.reasoning)
152
- }
153
-
154
134
  export function shouldInjectMemoryReminderFromLatestAssistant({
155
135
  lastMemoryReminderAssistantMessageId,
156
136
  latestAssistantMessage,
@@ -175,7 +155,10 @@ export function shouldInjectMemoryReminderFromLatestAssistant({
175
155
  if (lastMemoryReminderAssistantMessageId === latestAssistantMessage.id) {
176
156
  return { inject: false }
177
157
  }
178
- const outputTokens = getOutputTokenTotal(latestAssistantMessage.tokens)
158
+ const outputTokens = Math.max(
159
+ 0,
160
+ latestAssistantMessage.tokens.output + latestAssistantMessage.tokens.reasoning,
161
+ )
179
162
  if (outputTokens < threshold) {
180
163
  return { inject: false }
181
164
  }
@@ -311,7 +294,13 @@ const contextAwarenessPlugin: Plugin = async ({ directory, client }) => {
311
294
  if (existing) {
312
295
  return existing
313
296
  }
314
- const state = createSessionState()
297
+ const state: SessionState = {
298
+ gitState: undefined,
299
+ lastMemoryReminderAssistantMessageId: undefined,
300
+ tutorialInjected: false,
301
+ resolvedDirectory: undefined,
302
+ announcedDirectory: undefined,
303
+ }
315
304
  sessions.set(sessionID, state)
316
305
  return state
317
306
  }
@@ -412,26 +401,6 @@ const contextAwarenessPlugin: Plugin = async ({ directory, client }) => {
412
401
  })
413
402
  }
414
403
 
415
- // -- MEMORY.md injection --
416
- if (!state.memoryInjected) {
417
- state.memoryInjected = true
418
- const memoryPath = path.join(effectiveDirectory, 'MEMORY.md')
419
- const memoryContent = await fs.promises
420
- .readFile(memoryPath, 'utf-8')
421
- .catch(() => null)
422
- if (memoryContent) {
423
- const condensed = condenseMemoryMd(memoryContent)
424
- output.parts.push({
425
- id: `prt_${crypto.randomUUID()}`,
426
- sessionID,
427
- messageID,
428
- type: 'text' as const,
429
- text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>`,
430
- synthetic: true,
431
- })
432
- }
433
- }
434
-
435
404
  const memoryReminder = shouldInjectMemoryReminderFromLatestAssistant({
436
405
  lastMemoryReminderAssistantMessageId:
437
406
  state.lastMemoryReminderAssistantMessageId,
@@ -5,12 +5,14 @@
5
5
  //
6
6
  // Plugins are split into focused modules:
7
7
  // - ipc-tools-plugin: file upload + action buttons (IPC-based Discord tools)
8
- // - context-awareness-plugin: branch, pwd, memory, time gap, onboarding tutorial
8
+ // - context-awareness-plugin: branch, pwd, memory reminder, onboarding tutorial
9
+ // - memory-overview-plugin: frozen MEMORY.md heading overview per session
9
10
  // - opencode-interrupt-plugin: interrupt queued messages at step boundaries
10
11
  // - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
11
12
 
12
13
  export { ipcToolsPlugin } from './ipc-tools-plugin.js'
13
14
  export { contextAwarenessPlugin } from './context-awareness-plugin.js'
15
+ export { memoryOverviewPlugin } from './memory-overview-plugin.js'
14
16
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
15
17
  export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
16
18
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
@@ -0,0 +1,161 @@
1
+ // OpenCode plugin that snapshots the MEMORY.md heading overview once per
2
+ // session and injects that frozen snapshot on the first real user message.
3
+ // The snapshot is cached by session ID so later MEMORY.md edits do not change
4
+ // the prompt for the same session and do not invalidate OpenCode's cache.
5
+
6
+ import crypto from 'node:crypto'
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import type { Plugin } from '@opencode-ai/plugin'
10
+ import * as errore from 'errore'
11
+ import {
12
+ createPluginLogger,
13
+ formatPluginErrorWithStack,
14
+ setPluginLogFilePath,
15
+ } from './plugin-logger.js'
16
+ import { condenseMemoryMd } from './condense-memory.js'
17
+ import { initSentry, notifyError } from './sentry.js'
18
+
19
+ const logger = createPluginLogger('OPENCODE')
20
+
21
+ type SessionState = {
22
+ hasFrozenOverview: boolean
23
+ frozenOverviewText: string | null
24
+ injected: boolean
25
+ }
26
+
27
+ function createSessionState(): SessionState {
28
+ return {
29
+ hasFrozenOverview: false,
30
+ frozenOverviewText: null,
31
+ injected: false,
32
+ }
33
+ }
34
+
35
+ function buildMemoryOverviewReminder({ condensed }: { condensed: string }): string {
36
+ return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>`
37
+ }
38
+
39
+ async function freezeMemoryOverview({
40
+ directory,
41
+ state,
42
+ }: {
43
+ directory: string
44
+ state: SessionState
45
+ }): Promise<string | null> {
46
+ if (state.hasFrozenOverview) {
47
+ return state.frozenOverviewText
48
+ }
49
+
50
+ const memoryPath = path.join(directory, 'MEMORY.md')
51
+ const memoryContentResult = await fs.promises.readFile(memoryPath, 'utf-8').catch(() => {
52
+ return null
53
+ })
54
+ if (!memoryContentResult) {
55
+ state.hasFrozenOverview = true
56
+ state.frozenOverviewText = null
57
+ return null
58
+ }
59
+
60
+ const condensed = condenseMemoryMd(memoryContentResult)
61
+ state.hasFrozenOverview = true
62
+ state.frozenOverviewText = buildMemoryOverviewReminder({ condensed })
63
+ return state.frozenOverviewText
64
+ }
65
+
66
+ const memoryOverviewPlugin: Plugin = async ({ directory }) => {
67
+ initSentry()
68
+
69
+ const dataDir = process.env.KIMAKI_DATA_DIR
70
+ if (dataDir) {
71
+ setPluginLogFilePath(dataDir)
72
+ }
73
+
74
+ const sessions = new Map<string, SessionState>()
75
+
76
+ function getOrCreateSessionState({ sessionID }: { sessionID: string }): SessionState {
77
+ const existing = sessions.get(sessionID)
78
+ if (existing) {
79
+ return existing
80
+ }
81
+ const state = createSessionState()
82
+ sessions.set(sessionID, state)
83
+ return state
84
+ }
85
+
86
+ return {
87
+ 'chat.message': async (input, output) => {
88
+ const result = await errore.tryAsync({
89
+ try: async () => {
90
+ const state = getOrCreateSessionState({ sessionID: input.sessionID })
91
+ if (state.injected) {
92
+ return
93
+ }
94
+
95
+ const firstPart = output.parts.find((part) => {
96
+ if (part.type !== 'text') {
97
+ return true
98
+ }
99
+ return part.synthetic !== true
100
+ })
101
+ if (!firstPart || firstPart.type !== 'text' || firstPart.text.trim().length === 0) {
102
+ return
103
+ }
104
+
105
+ const overviewText = await freezeMemoryOverview({ directory, state })
106
+ state.injected = true
107
+ if (!overviewText) {
108
+ return
109
+ }
110
+
111
+ output.parts.push({
112
+ id: `prt_${crypto.randomUUID()}`,
113
+ sessionID: input.sessionID,
114
+ messageID: firstPart.messageID,
115
+ type: 'text' as const,
116
+ text: overviewText,
117
+ synthetic: true,
118
+ })
119
+ },
120
+ catch: (error) => {
121
+ return new Error('memory overview chat.message hook failed', {
122
+ cause: error,
123
+ })
124
+ },
125
+ })
126
+ if (!(result instanceof Error)) {
127
+ return
128
+ }
129
+ logger.warn(
130
+ `[memory-overview-plugin] ${formatPluginErrorWithStack(result)}`,
131
+ )
132
+ void notifyError(result, 'memory overview plugin chat.message hook failed')
133
+ },
134
+ event: async ({ event }) => {
135
+ const result = await errore.tryAsync({
136
+ try: async () => {
137
+ if (event.type !== 'session.deleted') {
138
+ return
139
+ }
140
+ const id = event.properties?.info?.id
141
+ if (!id) {
142
+ return
143
+ }
144
+ sessions.delete(id)
145
+ },
146
+ catch: (error) => {
147
+ return new Error('memory overview event hook failed', {
148
+ cause: error,
149
+ })
150
+ },
151
+ })
152
+ if (!(result instanceof Error)) {
153
+ return
154
+ }
155
+ logger.warn(`[memory-overview-plugin] ${formatPluginErrorWithStack(result)}`)
156
+ void notifyError(result, 'memory overview plugin event hook failed')
157
+ },
158
+ }
159
+ }
160
+
161
+ export { memoryOverviewPlugin }
package/src/opencode.ts CHANGED
@@ -489,6 +489,9 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
489
489
  const opencodeConfigDir = path
490
490
  .join(os.homedir(), '.config', 'opencode')
491
491
  .replaceAll('\\', '/')
492
+ const opensrcDir = path
493
+ .join(os.homedir(), '.opensrc')
494
+ .replaceAll('\\', '/')
492
495
  const kimakiDataDir = path
493
496
  .join(os.homedir(), '.kimaki')
494
497
  .replaceAll('\\', '/')
@@ -503,6 +506,8 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
503
506
  [`${tmpdir}/*`]: 'allow',
504
507
  [opencodeConfigDir]: 'allow',
505
508
  [`${opencodeConfigDir}/*`]: 'allow',
509
+ [opensrcDir]: 'allow',
510
+ [`${opensrcDir}/*`]: 'allow',
506
511
  [kimakiDataDir]: 'allow',
507
512
  [`${kimakiDataDir}/*`]: 'allow',
508
513
  }
@@ -543,11 +548,17 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
543
548
  // priority chain, so project-level opencode.json can override kimaki defaults.
544
549
  // OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
545
550
  // causing issue #90 (project permissions not being respected).
551
+ const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx')
546
552
  const opencodeConfig = {
547
553
  $schema: 'https://opencode.ai/config.json',
548
554
  lsp: false,
549
555
  formatter: false,
550
- plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
556
+ plugin: [
557
+ new URL(
558
+ isDev ? './kimaki-opencode-plugin.ts' : './kimaki-opencode-plugin.js',
559
+ import.meta.url,
560
+ ).href,
561
+ ],
551
562
  permission: {
552
563
  edit: 'allow',
553
564
  bash: 'allow',
@@ -878,6 +889,16 @@ export function buildSessionPermissions({
878
889
  { permission: 'external_directory', pattern: `${opencodeConfigDir}/*`, action: 'allow' },
879
890
  )
880
891
 
892
+ // Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
893
+ // permission prompts.
894
+ const opensrcDir = path
895
+ .join(os.homedir(), '.opensrc')
896
+ .replaceAll('\\', '/')
897
+ rules.push(
898
+ { permission: 'external_directory', pattern: opensrcDir, action: 'allow' },
899
+ { permission: 'external_directory', pattern: `${opensrcDir}/*`, action: 'allow' },
900
+ )
901
+
881
902
  // Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
882
903
  // without permission prompts.
883
904
  const kimakiDataDir = path