@otto-assistant/bridge 0.4.97 → 0.4.101
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.
- package/dist/agent-model.e2e.test.js +7 -1
- package/dist/anthropic-auth-plugin.js +227 -176
- package/dist/cli-send-thread.e2e.test.js +4 -7
- package/dist/cli.js +2 -2
- package/dist/commands/login.js +6 -4
- package/dist/commands/screenshare.js +1 -1
- package/dist/commands/screenshare.test.js +2 -2
- package/dist/commands/vscode.js +269 -0
- package/dist/context-awareness-plugin.js +8 -38
- package/dist/db.js +1 -0
- package/dist/discord-command-registration.js +5 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
- package/dist/interaction-handler.js +4 -0
- package/dist/kimaki-opencode-plugin.js +3 -1
- package/dist/memory-overview-plugin.js +126 -0
- package/dist/system-message.js +23 -22
- package/dist/system-message.test.js +23 -22
- package/dist/system-prompt-drift-plugin.js +41 -11
- package/dist/utils.js +1 -1
- package/package.json +1 -1
- package/src/agent-model.e2e.test.ts +8 -1
- package/src/anthropic-auth-plugin.ts +574 -451
- package/src/cli-send-thread.e2e.test.ts +6 -7
- package/src/cli.ts +2 -2
- package/src/commands/login.ts +6 -4
- package/src/commands/screenshare.test.ts +2 -2
- package/src/commands/screenshare.ts +1 -1
- package/src/commands/vscode.ts +342 -0
- package/src/context-awareness-plugin.ts +11 -42
- package/src/db.ts +1 -0
- package/src/discord-command-registration.ts +7 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
- package/src/interaction-handler.ts +5 -0
- package/src/kimaki-opencode-plugin.ts +3 -1
- package/src/memory-overview-plugin.ts +161 -0
- package/src/system-message.test.ts +23 -22
- package/src/system-message.ts +23 -22
- package/src/system-prompt-drift-plugin.ts +48 -12
- package/src/utils.ts +1 -1
|
@@ -339,14 +339,13 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
339
339
|
})
|
|
340
340
|
|
|
341
341
|
const allContent = botReplies.map((m) => {
|
|
342
|
-
return m.content
|
|
342
|
+
return m.content
|
|
343
343
|
})
|
|
344
|
-
expect(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
`)
|
|
344
|
+
expect(
|
|
345
|
+
allContent.some((content) => {
|
|
346
|
+
return content.includes('Command not found: "hello-test"')
|
|
347
|
+
}),
|
|
348
|
+
).toBe(true)
|
|
350
349
|
} finally {
|
|
351
350
|
store.setState({ registeredUserCommands: prevCommands })
|
|
352
351
|
}
|
package/src/cli.ts
CHANGED
|
@@ -141,7 +141,7 @@ const cliLogger = createLogger(LogPrefix.CLI)
|
|
|
141
141
|
// These are hardcoded because they're deploy-time constants for the gateway infrastructure.
|
|
142
142
|
const KIMAKI_GATEWAY_PROXY_URL =
|
|
143
143
|
process.env.KIMAKI_GATEWAY_PROXY_URL ||
|
|
144
|
-
'wss://discord-gateway.kimaki.
|
|
144
|
+
'wss://discord-gateway.kimaki.dev'
|
|
145
145
|
|
|
146
146
|
const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
|
|
147
147
|
gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
|
|
@@ -3792,7 +3792,7 @@ cli
|
|
|
3792
3792
|
port,
|
|
3793
3793
|
tunnelId: options.tunnelId,
|
|
3794
3794
|
localHost: options.host,
|
|
3795
|
-
baseDomain: 'kimaki.
|
|
3795
|
+
baseDomain: 'kimaki.dev',
|
|
3796
3796
|
serverUrl: options.server,
|
|
3797
3797
|
command: command.length > 0 ? command : undefined,
|
|
3798
3798
|
kill: options.kill,
|
package/src/commands/login.ts
CHANGED
|
@@ -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',
|
|
@@ -17,12 +17,12 @@ describe('screenshare security defaults', () => {
|
|
|
17
17
|
|
|
18
18
|
test('builds a secure noVNC URL', () => {
|
|
19
19
|
const url = new URL(
|
|
20
|
-
buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.
|
|
20
|
+
buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.dev' }),
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
expect(url.origin).toBe('https://novnc.com')
|
|
24
24
|
expect(url.searchParams.get('host')).toBe(
|
|
25
|
-
'0123456789abcdef-tunnel.kimaki.
|
|
25
|
+
'0123456789abcdef-tunnel.kimaki.dev',
|
|
26
26
|
)
|
|
27
27
|
expect(url.searchParams.get('port')).toBe('443')
|
|
28
28
|
expect(url.searchParams.get('encrypt')).toBe('1')
|
|
@@ -40,7 +40,7 @@ const activeSessions = new Map<string, ScreenshareSession>()
|
|
|
40
40
|
const VNC_PORT = 5900
|
|
41
41
|
const MAX_SESSION_MINUTES = 30
|
|
42
42
|
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
|
|
43
|
-
const TUNNEL_BASE_DOMAIN = 'kimaki.
|
|
43
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
|
|
44
44
|
const SCREENSHARE_TUNNEL_ID_BYTES = 16
|
|
45
45
|
|
|
46
46
|
// Public noVNC client — we point it at our tunnel URL
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import {
|
|
5
|
+
ChannelType,
|
|
6
|
+
MessageFlags,
|
|
7
|
+
type TextChannel,
|
|
8
|
+
type ThreadChannel,
|
|
9
|
+
} from 'discord.js'
|
|
10
|
+
import { TunnelClient } from 'traforo/client'
|
|
11
|
+
import type { CommandContext } from './types.js'
|
|
12
|
+
import {
|
|
13
|
+
resolveWorkingDirectory,
|
|
14
|
+
SILENT_MESSAGE_FLAGS,
|
|
15
|
+
} from '../discord-utils.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
|
|
18
|
+
const logger = createLogger('VSCODE')
|
|
19
|
+
const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS
|
|
20
|
+
const MAX_SESSION_MINUTES = 30
|
|
21
|
+
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
|
|
22
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
|
|
23
|
+
const TUNNEL_ID_BYTES = 16
|
|
24
|
+
const READY_TIMEOUT_MS = 60_000
|
|
25
|
+
const LOCAL_HOST = '127.0.0.1'
|
|
26
|
+
|
|
27
|
+
export type VscodeSession = {
|
|
28
|
+
coderaftProcess: ChildProcess
|
|
29
|
+
tunnelClient: TunnelClient
|
|
30
|
+
url: string
|
|
31
|
+
workingDirectory: string
|
|
32
|
+
startedBy: string
|
|
33
|
+
startedAt: number
|
|
34
|
+
timeoutTimer: ReturnType<typeof setTimeout>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const activeSessions = new Map<string, VscodeSession>()
|
|
38
|
+
|
|
39
|
+
export function createVscodeTunnelId(): string {
|
|
40
|
+
return crypto.randomBytes(TUNNEL_ID_BYTES).toString('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildCoderaftArgs({
|
|
44
|
+
port,
|
|
45
|
+
workingDirectory,
|
|
46
|
+
}: {
|
|
47
|
+
port: number
|
|
48
|
+
workingDirectory: string
|
|
49
|
+
}): string[] {
|
|
50
|
+
return [
|
|
51
|
+
'coderaft',
|
|
52
|
+
'--port',
|
|
53
|
+
String(port),
|
|
54
|
+
'--host',
|
|
55
|
+
LOCAL_HOST,
|
|
56
|
+
'--without-connection-token',
|
|
57
|
+
'--disable-workspace-trust',
|
|
58
|
+
'--default-folder',
|
|
59
|
+
workingDirectory,
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createPortWaiter({
|
|
64
|
+
port,
|
|
65
|
+
process: proc,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
}: {
|
|
68
|
+
port: number
|
|
69
|
+
process: ChildProcess
|
|
70
|
+
timeoutMs: number
|
|
71
|
+
}): Promise<void> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const maxAttempts = Math.ceil(timeoutMs / 100)
|
|
74
|
+
let attempts = 0
|
|
75
|
+
|
|
76
|
+
const check = (): void => {
|
|
77
|
+
if (proc.exitCode !== null) {
|
|
78
|
+
reject(new Error(`coderaft exited with code ${proc.exitCode} before becoming ready`))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const socket = net.createConnection(port, LOCAL_HOST)
|
|
83
|
+
socket.on('connect', () => {
|
|
84
|
+
socket.destroy()
|
|
85
|
+
resolve()
|
|
86
|
+
})
|
|
87
|
+
socket.on('error', () => {
|
|
88
|
+
socket.destroy()
|
|
89
|
+
attempts += 1
|
|
90
|
+
if (attempts >= maxAttempts) {
|
|
91
|
+
reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
setTimeout(check, 100)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
check()
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getAvailablePort(): Promise<number> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const server = net.createServer()
|
|
105
|
+
server.on('error', reject)
|
|
106
|
+
server.listen(0, LOCAL_HOST, () => {
|
|
107
|
+
const address = server.address()
|
|
108
|
+
if (!address || typeof address === 'string') {
|
|
109
|
+
server.close(() => {
|
|
110
|
+
reject(new Error('Failed to resolve an available port'))
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const port = address.port
|
|
115
|
+
server.close((error) => {
|
|
116
|
+
if (error) {
|
|
117
|
+
reject(error)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
resolve(port)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cleanupSession(session: VscodeSession): void {
|
|
127
|
+
clearTimeout(session.timeoutTimer)
|
|
128
|
+
try {
|
|
129
|
+
session.tunnelClient.close()
|
|
130
|
+
} catch {}
|
|
131
|
+
if (session.coderaftProcess.exitCode === null) {
|
|
132
|
+
try {
|
|
133
|
+
session.coderaftProcess.kill('SIGTERM')
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getActiveVscodeSession({ sessionKey }: { sessionKey: string }): VscodeSession | undefined {
|
|
139
|
+
return activeSessions.get(sessionKey)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function stopVscode({ sessionKey }: { sessionKey: string }): boolean {
|
|
143
|
+
const session = activeSessions.get(sessionKey)
|
|
144
|
+
if (!session) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
activeSessions.delete(sessionKey)
|
|
149
|
+
cleanupSession(session)
|
|
150
|
+
logger.log(`VS Code stopped (key: ${sessionKey})`)
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function startVscode({
|
|
155
|
+
sessionKey,
|
|
156
|
+
startedBy,
|
|
157
|
+
workingDirectory,
|
|
158
|
+
}: {
|
|
159
|
+
sessionKey: string
|
|
160
|
+
startedBy: string
|
|
161
|
+
workingDirectory: string
|
|
162
|
+
}): Promise<VscodeSession> {
|
|
163
|
+
const existing = activeSessions.get(sessionKey)
|
|
164
|
+
if (existing) {
|
|
165
|
+
return existing
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const port = await getAvailablePort()
|
|
169
|
+
const tunnelId = createVscodeTunnelId()
|
|
170
|
+
const args = buildCoderaftArgs({
|
|
171
|
+
port,
|
|
172
|
+
workingDirectory,
|
|
173
|
+
})
|
|
174
|
+
const coderaftProcess = spawn('bunx', args, {
|
|
175
|
+
cwd: workingDirectory,
|
|
176
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
177
|
+
env: {
|
|
178
|
+
...process.env,
|
|
179
|
+
PORT: String(port),
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
coderaftProcess.stdout?.on('data', (data: Buffer) => {
|
|
184
|
+
logger.log(data.toString().trim())
|
|
185
|
+
})
|
|
186
|
+
coderaftProcess.stderr?.on('data', (data: Buffer) => {
|
|
187
|
+
logger.error(data.toString().trim())
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await createPortWaiter({
|
|
192
|
+
port,
|
|
193
|
+
process: coderaftProcess,
|
|
194
|
+
timeoutMs: READY_TIMEOUT_MS,
|
|
195
|
+
})
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (coderaftProcess.exitCode === null) {
|
|
198
|
+
coderaftProcess.kill('SIGTERM')
|
|
199
|
+
}
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tunnelClient = new TunnelClient({
|
|
204
|
+
localPort: port,
|
|
205
|
+
localHost: LOCAL_HOST,
|
|
206
|
+
tunnelId,
|
|
207
|
+
baseDomain: TUNNEL_BASE_DOMAIN,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await Promise.race([
|
|
212
|
+
tunnelClient.connect(),
|
|
213
|
+
new Promise<never>((_, reject) => {
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
reject(new Error('Tunnel connection timed out after 15s'))
|
|
216
|
+
}, 15_000)
|
|
217
|
+
}),
|
|
218
|
+
])
|
|
219
|
+
} catch (error) {
|
|
220
|
+
tunnelClient.close()
|
|
221
|
+
if (coderaftProcess.exitCode === null) {
|
|
222
|
+
coderaftProcess.kill('SIGTERM')
|
|
223
|
+
}
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const url = tunnelClient.url
|
|
228
|
+
|
|
229
|
+
const timeoutTimer = setTimeout(() => {
|
|
230
|
+
logger.log(`VS Code auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`)
|
|
231
|
+
stopVscode({ sessionKey })
|
|
232
|
+
}, MAX_SESSION_MS)
|
|
233
|
+
timeoutTimer.unref()
|
|
234
|
+
|
|
235
|
+
const session: VscodeSession = {
|
|
236
|
+
coderaftProcess,
|
|
237
|
+
tunnelClient,
|
|
238
|
+
url,
|
|
239
|
+
workingDirectory,
|
|
240
|
+
startedBy,
|
|
241
|
+
startedAt: Date.now(),
|
|
242
|
+
timeoutTimer,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
coderaftProcess.once('exit', (code, signal) => {
|
|
246
|
+
const current = activeSessions.get(sessionKey)
|
|
247
|
+
if (current !== session) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
logger.log(`VS Code process exited (key: ${sessionKey}, code: ${code}, signal: ${signal ?? 'none'})`)
|
|
251
|
+
stopVscode({ sessionKey })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
activeSessions.set(sessionKey, session)
|
|
255
|
+
logger.log(`VS Code started by ${startedBy}: ${url}`)
|
|
256
|
+
return session
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function handleVscodeCommand({
|
|
260
|
+
command,
|
|
261
|
+
}: CommandContext): Promise<void> {
|
|
262
|
+
const channel = command.channel
|
|
263
|
+
if (!channel) {
|
|
264
|
+
await command.reply({
|
|
265
|
+
content: 'This command can only be used in a channel.',
|
|
266
|
+
flags: SECURE_REPLY_FLAGS,
|
|
267
|
+
})
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isThread = [
|
|
272
|
+
ChannelType.PublicThread,
|
|
273
|
+
ChannelType.PrivateThread,
|
|
274
|
+
ChannelType.AnnouncementThread,
|
|
275
|
+
].includes(channel.type)
|
|
276
|
+
const isTextChannel = channel.type === ChannelType.GuildText
|
|
277
|
+
if (!isThread && !isTextChannel) {
|
|
278
|
+
await command.reply({
|
|
279
|
+
content: 'This command can only be used in a text channel or thread.',
|
|
280
|
+
flags: SECURE_REPLY_FLAGS,
|
|
281
|
+
})
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const resolved = await resolveWorkingDirectory({
|
|
286
|
+
channel: channel as TextChannel | ThreadChannel,
|
|
287
|
+
})
|
|
288
|
+
if (!resolved) {
|
|
289
|
+
await command.reply({
|
|
290
|
+
content: 'Could not determine project directory for this channel.',
|
|
291
|
+
flags: SECURE_REPLY_FLAGS,
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await command.deferReply({ flags: SECURE_REPLY_FLAGS })
|
|
297
|
+
|
|
298
|
+
const sessionKey = channel.id
|
|
299
|
+
const existing = getActiveVscodeSession({ sessionKey })
|
|
300
|
+
if (existing) {
|
|
301
|
+
await command.editReply({
|
|
302
|
+
content:
|
|
303
|
+
`VS Code is already running for this thread. ` +
|
|
304
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes from startup.\n` +
|
|
305
|
+
`${existing.url}`,
|
|
306
|
+
})
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const session = await startVscode({
|
|
312
|
+
sessionKey,
|
|
313
|
+
startedBy: command.user.tag,
|
|
314
|
+
workingDirectory: resolved.workingDirectory,
|
|
315
|
+
})
|
|
316
|
+
await command.editReply({
|
|
317
|
+
content:
|
|
318
|
+
`VS Code started for \`${session.workingDirectory}\`. ` +
|
|
319
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes, so open it before it expires.\n` +
|
|
320
|
+
`${session.url}`,
|
|
321
|
+
})
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.error('Failed to start VS Code:', error)
|
|
324
|
+
await command.editReply({
|
|
325
|
+
content: `Failed to start VS Code: ${error instanceof Error ? error.message : String(error)}`,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function cleanupAllVscodeSessions(): void {
|
|
331
|
+
for (const sessionKey of activeSessions.keys()) {
|
|
332
|
+
stopVscode({ sessionKey })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onProcessExit(): void {
|
|
337
|
+
cleanupAllVscodeSessions()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
process.on('SIGINT', onProcessExit)
|
|
341
|
+
process.on('SIGTERM', onProcessExit)
|
|
342
|
+
process.on('exit', onProcessExit)
|
|
@@ -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 =
|
|
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 =
|
|
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,
|
package/src/db.ts
CHANGED
|
@@ -235,6 +235,7 @@ async function migrateSchema(prisma: PrismaClient): Promise<void> {
|
|
|
235
235
|
// Also fix NULL worktree status rows that predate the required enum.
|
|
236
236
|
const defensiveMigrations = [
|
|
237
237
|
"UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
|
|
238
|
+
"UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
|
|
238
239
|
"UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
|
|
239
240
|
]
|
|
240
241
|
for (const stmt of defensiveMigrations) {
|
|
@@ -488,6 +488,13 @@ export async function registerCommands({
|
|
|
488
488
|
.setDescription(truncateCommandDescription('Stop screen sharing'))
|
|
489
489
|
.setDMPermission(false)
|
|
490
490
|
.toJSON(),
|
|
491
|
+
new SlashCommandBuilder()
|
|
492
|
+
.setName('vscode')
|
|
493
|
+
.setDescription(
|
|
494
|
+
truncateCommandDescription('Open VS Code in the browser for this project or worktree (auto-stops after 30 minutes)'),
|
|
495
|
+
)
|
|
496
|
+
.setDMPermission(false)
|
|
497
|
+
.toJSON(),
|
|
491
498
|
]
|
|
492
499
|
|
|
493
500
|
// Dynamic commands are registered in priority order: agents → user commands → skills → MCP prompts.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Starts a digital-twin + local gateway-proxy binary, kills and restarts the proxy.
|
|
7
7
|
//
|
|
8
8
|
// Production mode (env vars):
|
|
9
|
-
// GATEWAY_TEST_URL - production gateway WS+REST URL (e.g. wss://discord-gateway.kimaki.
|
|
9
|
+
// GATEWAY_TEST_URL - production gateway WS+REST URL (e.g. wss://discord-gateway.kimaki.dev)
|
|
10
10
|
// GATEWAY_TEST_TOKEN - client token (clientId:secret)
|
|
11
11
|
// GATEWAY_TEST_REDEPLOY - if "1", runs `fly deploy` between kill/restart instead of local binary
|
|
12
12
|
//
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// pnpm test --run src/gateway-proxy-reconnect.e2e.test.ts
|
|
16
16
|
//
|
|
17
17
|
// # Against production (just connect + kill WS + wait for reconnect):
|
|
18
|
-
// GATEWAY_TEST_URL=wss://discord-gateway.kimaki.
|
|
18
|
+
// GATEWAY_TEST_URL=wss://discord-gateway.kimaki.dev \
|
|
19
19
|
// GATEWAY_TEST_TOKEN=myclientid:mysecret \
|
|
20
20
|
// KIMAKI_TEST_LOGS=1 \
|
|
21
21
|
// pnpm test --run src/gateway-proxy-reconnect.e2e.test.ts -t "production"
|
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
handleScreenshareCommand,
|
|
102
102
|
handleScreenshareStopCommand,
|
|
103
103
|
} from './commands/screenshare.js'
|
|
104
|
+
import { handleVscodeCommand } from './commands/vscode.js'
|
|
104
105
|
import { handleModelVariantSelectMenu } from './commands/model.js'
|
|
105
106
|
import {
|
|
106
107
|
handleModelVariantCommand,
|
|
@@ -356,6 +357,10 @@ export function registerInteractionHandler({
|
|
|
356
357
|
appId,
|
|
357
358
|
})
|
|
358
359
|
return
|
|
360
|
+
|
|
361
|
+
case 'vscode':
|
|
362
|
+
await handleVscodeCommand({ command: interaction, appId })
|
|
363
|
+
return
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
@@ -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
|
|
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'
|