@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
|
@@ -257,14 +257,11 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
257
257
|
return m.author.id === discord.botUserId && m.id !== starterMessage.id;
|
|
258
258
|
});
|
|
259
259
|
const allContent = botReplies.map((m) => {
|
|
260
|
-
return m.content
|
|
260
|
+
return m.content;
|
|
261
261
|
});
|
|
262
|
-
expect(allContent
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
"✗ OpenCode API error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x-art",
|
|
266
|
-
]
|
|
267
|
-
`);
|
|
262
|
+
expect(allContent.some((content) => {
|
|
263
|
+
return content.includes('Command not found: "hello-test"');
|
|
264
|
+
})).toBe(true);
|
|
268
265
|
}
|
|
269
266
|
finally {
|
|
270
267
|
store.setState({ registeredUserCommands: prevCommands });
|
package/dist/cli.js
CHANGED
|
@@ -41,7 +41,7 @@ const cliLogger = createLogger(LogPrefix.CLI);
|
|
|
41
41
|
// We derive REST base from this URL by swapping ws/wss to http/https.
|
|
42
42
|
// These are hardcoded because they're deploy-time constants for the gateway infrastructure.
|
|
43
43
|
const KIMAKI_GATEWAY_PROXY_URL = process.env.KIMAKI_GATEWAY_PROXY_URL ||
|
|
44
|
-
'wss://discord-gateway.kimaki.
|
|
44
|
+
'wss://discord-gateway.kimaki.dev';
|
|
45
45
|
const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
|
|
46
46
|
gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
|
|
47
47
|
});
|
|
@@ -2695,7 +2695,7 @@ cli
|
|
|
2695
2695
|
port,
|
|
2696
2696
|
tunnelId: options.tunnelId,
|
|
2697
2697
|
localHost: options.host,
|
|
2698
|
-
baseDomain: 'kimaki.
|
|
2698
|
+
baseDomain: 'kimaki.dev',
|
|
2699
2699
|
serverUrl: options.server,
|
|
2700
2700
|
command: command.length > 0 ? command : undefined,
|
|
2701
2701
|
kill: options.kill,
|
package/dist/commands/login.js
CHANGED
|
@@ -39,6 +39,8 @@ const PROVIDER_POPULARITY_ORDER = [
|
|
|
39
39
|
'xai',
|
|
40
40
|
'groq',
|
|
41
41
|
'deepseek',
|
|
42
|
+
'opencode',
|
|
43
|
+
'opencode-go',
|
|
42
44
|
'mistral',
|
|
43
45
|
'openrouter',
|
|
44
46
|
'fireworks-ai',
|
|
@@ -47,12 +49,12 @@ const PROVIDER_POPULARITY_ORDER = [
|
|
|
47
49
|
'azure',
|
|
48
50
|
'google-vertex',
|
|
49
51
|
'google-vertex-anthropic',
|
|
50
|
-
'cohere',
|
|
52
|
+
// 'cohere',
|
|
51
53
|
'cerebras',
|
|
52
|
-
'perplexity',
|
|
54
|
+
// 'perplexity',
|
|
53
55
|
'cloudflare-workers-ai',
|
|
54
|
-
'novita-ai',
|
|
55
|
-
'huggingface',
|
|
56
|
+
// 'novita-ai',
|
|
57
|
+
// 'huggingface',
|
|
56
58
|
'deepinfra',
|
|
57
59
|
'github-models',
|
|
58
60
|
'lmstudio',
|
|
@@ -21,7 +21,7 @@ const activeSessions = new Map();
|
|
|
21
21
|
const VNC_PORT = 5900;
|
|
22
22
|
const MAX_SESSION_MINUTES = 30;
|
|
23
23
|
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000;
|
|
24
|
-
const TUNNEL_BASE_DOMAIN = 'kimaki.
|
|
24
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev';
|
|
25
25
|
const SCREENSHARE_TUNNEL_ID_BYTES = 16;
|
|
26
26
|
// Public noVNC client — we point it at our tunnel URL
|
|
27
27
|
export function buildNoVncUrl({ tunnelHost }) {
|
|
@@ -11,9 +11,9 @@ describe('screenshare security defaults', () => {
|
|
|
11
11
|
}
|
|
12
12
|
});
|
|
13
13
|
test('builds a secure noVNC URL', () => {
|
|
14
|
-
const url = new URL(buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.
|
|
14
|
+
const url = new URL(buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.dev' }));
|
|
15
15
|
expect(url.origin).toBe('https://novnc.com');
|
|
16
|
-
expect(url.searchParams.get('host')).toBe('0123456789abcdef-tunnel.kimaki.
|
|
16
|
+
expect(url.searchParams.get('host')).toBe('0123456789abcdef-tunnel.kimaki.dev');
|
|
17
17
|
expect(url.searchParams.get('port')).toBe('443');
|
|
18
18
|
expect(url.searchParams.get('encrypt')).toBe('1');
|
|
19
19
|
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
5
|
+
import { TunnelClient } from 'traforo/client';
|
|
6
|
+
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const logger = createLogger('VSCODE');
|
|
9
|
+
const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS;
|
|
10
|
+
const MAX_SESSION_MINUTES = 30;
|
|
11
|
+
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000;
|
|
12
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev';
|
|
13
|
+
const TUNNEL_ID_BYTES = 16;
|
|
14
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
15
|
+
const LOCAL_HOST = '127.0.0.1';
|
|
16
|
+
const activeSessions = new Map();
|
|
17
|
+
export function createVscodeTunnelId() {
|
|
18
|
+
return crypto.randomBytes(TUNNEL_ID_BYTES).toString('hex');
|
|
19
|
+
}
|
|
20
|
+
export function buildCoderaftArgs({ port, workingDirectory, }) {
|
|
21
|
+
return [
|
|
22
|
+
'coderaft',
|
|
23
|
+
'--port',
|
|
24
|
+
String(port),
|
|
25
|
+
'--host',
|
|
26
|
+
LOCAL_HOST,
|
|
27
|
+
'--without-connection-token',
|
|
28
|
+
'--disable-workspace-trust',
|
|
29
|
+
'--default-folder',
|
|
30
|
+
workingDirectory,
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
function createPortWaiter({ port, process: proc, timeoutMs, }) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const maxAttempts = Math.ceil(timeoutMs / 100);
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
const check = () => {
|
|
38
|
+
if (proc.exitCode !== null) {
|
|
39
|
+
reject(new Error(`coderaft exited with code ${proc.exitCode} before becoming ready`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const socket = net.createConnection(port, LOCAL_HOST);
|
|
43
|
+
socket.on('connect', () => {
|
|
44
|
+
socket.destroy();
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
socket.on('error', () => {
|
|
48
|
+
socket.destroy();
|
|
49
|
+
attempts += 1;
|
|
50
|
+
if (attempts >= maxAttempts) {
|
|
51
|
+
reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setTimeout(check, 100);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
check();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function getAvailablePort() {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const server = net.createServer();
|
|
63
|
+
server.on('error', reject);
|
|
64
|
+
server.listen(0, LOCAL_HOST, () => {
|
|
65
|
+
const address = server.address();
|
|
66
|
+
if (!address || typeof address === 'string') {
|
|
67
|
+
server.close(() => {
|
|
68
|
+
reject(new Error('Failed to resolve an available port'));
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const port = address.port;
|
|
73
|
+
server.close((error) => {
|
|
74
|
+
if (error) {
|
|
75
|
+
reject(error);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
resolve(port);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function cleanupSession(session) {
|
|
84
|
+
clearTimeout(session.timeoutTimer);
|
|
85
|
+
try {
|
|
86
|
+
session.tunnelClient.close();
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
if (session.coderaftProcess.exitCode === null) {
|
|
90
|
+
try {
|
|
91
|
+
session.coderaftProcess.kill('SIGTERM');
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function getActiveVscodeSession({ sessionKey }) {
|
|
97
|
+
return activeSessions.get(sessionKey);
|
|
98
|
+
}
|
|
99
|
+
export function stopVscode({ sessionKey }) {
|
|
100
|
+
const session = activeSessions.get(sessionKey);
|
|
101
|
+
if (!session) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
activeSessions.delete(sessionKey);
|
|
105
|
+
cleanupSession(session);
|
|
106
|
+
logger.log(`VS Code stopped (key: ${sessionKey})`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
export async function startVscode({ sessionKey, startedBy, workingDirectory, }) {
|
|
110
|
+
const existing = activeSessions.get(sessionKey);
|
|
111
|
+
if (existing) {
|
|
112
|
+
return existing;
|
|
113
|
+
}
|
|
114
|
+
const port = await getAvailablePort();
|
|
115
|
+
const tunnelId = createVscodeTunnelId();
|
|
116
|
+
const args = buildCoderaftArgs({
|
|
117
|
+
port,
|
|
118
|
+
workingDirectory,
|
|
119
|
+
});
|
|
120
|
+
const coderaftProcess = spawn('bunx', args, {
|
|
121
|
+
cwd: workingDirectory,
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
env: {
|
|
124
|
+
...process.env,
|
|
125
|
+
PORT: String(port),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
coderaftProcess.stdout?.on('data', (data) => {
|
|
129
|
+
logger.log(data.toString().trim());
|
|
130
|
+
});
|
|
131
|
+
coderaftProcess.stderr?.on('data', (data) => {
|
|
132
|
+
logger.error(data.toString().trim());
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
await createPortWaiter({
|
|
136
|
+
port,
|
|
137
|
+
process: coderaftProcess,
|
|
138
|
+
timeoutMs: READY_TIMEOUT_MS,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (coderaftProcess.exitCode === null) {
|
|
143
|
+
coderaftProcess.kill('SIGTERM');
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const tunnelClient = new TunnelClient({
|
|
148
|
+
localPort: port,
|
|
149
|
+
localHost: LOCAL_HOST,
|
|
150
|
+
tunnelId,
|
|
151
|
+
baseDomain: TUNNEL_BASE_DOMAIN,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
await Promise.race([
|
|
155
|
+
tunnelClient.connect(),
|
|
156
|
+
new Promise((_, reject) => {
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
reject(new Error('Tunnel connection timed out after 15s'));
|
|
159
|
+
}, 15_000);
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
tunnelClient.close();
|
|
165
|
+
if (coderaftProcess.exitCode === null) {
|
|
166
|
+
coderaftProcess.kill('SIGTERM');
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
const url = tunnelClient.url;
|
|
171
|
+
const timeoutTimer = setTimeout(() => {
|
|
172
|
+
logger.log(`VS Code auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`);
|
|
173
|
+
stopVscode({ sessionKey });
|
|
174
|
+
}, MAX_SESSION_MS);
|
|
175
|
+
timeoutTimer.unref();
|
|
176
|
+
const session = {
|
|
177
|
+
coderaftProcess,
|
|
178
|
+
tunnelClient,
|
|
179
|
+
url,
|
|
180
|
+
workingDirectory,
|
|
181
|
+
startedBy,
|
|
182
|
+
startedAt: Date.now(),
|
|
183
|
+
timeoutTimer,
|
|
184
|
+
};
|
|
185
|
+
coderaftProcess.once('exit', (code, signal) => {
|
|
186
|
+
const current = activeSessions.get(sessionKey);
|
|
187
|
+
if (current !== session) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
logger.log(`VS Code process exited (key: ${sessionKey}, code: ${code}, signal: ${signal ?? 'none'})`);
|
|
191
|
+
stopVscode({ sessionKey });
|
|
192
|
+
});
|
|
193
|
+
activeSessions.set(sessionKey, session);
|
|
194
|
+
logger.log(`VS Code started by ${startedBy}: ${url}`);
|
|
195
|
+
return session;
|
|
196
|
+
}
|
|
197
|
+
export async function handleVscodeCommand({ command, }) {
|
|
198
|
+
const channel = command.channel;
|
|
199
|
+
if (!channel) {
|
|
200
|
+
await command.reply({
|
|
201
|
+
content: 'This command can only be used in a channel.',
|
|
202
|
+
flags: SECURE_REPLY_FLAGS,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const isThread = [
|
|
207
|
+
ChannelType.PublicThread,
|
|
208
|
+
ChannelType.PrivateThread,
|
|
209
|
+
ChannelType.AnnouncementThread,
|
|
210
|
+
].includes(channel.type);
|
|
211
|
+
const isTextChannel = channel.type === ChannelType.GuildText;
|
|
212
|
+
if (!isThread && !isTextChannel) {
|
|
213
|
+
await command.reply({
|
|
214
|
+
content: 'This command can only be used in a text channel or thread.',
|
|
215
|
+
flags: SECURE_REPLY_FLAGS,
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const resolved = await resolveWorkingDirectory({
|
|
220
|
+
channel: channel,
|
|
221
|
+
});
|
|
222
|
+
if (!resolved) {
|
|
223
|
+
await command.reply({
|
|
224
|
+
content: 'Could not determine project directory for this channel.',
|
|
225
|
+
flags: SECURE_REPLY_FLAGS,
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await command.deferReply({ flags: SECURE_REPLY_FLAGS });
|
|
230
|
+
const sessionKey = channel.id;
|
|
231
|
+
const existing = getActiveVscodeSession({ sessionKey });
|
|
232
|
+
if (existing) {
|
|
233
|
+
await command.editReply({
|
|
234
|
+
content: `VS Code is already running for this thread. ` +
|
|
235
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes from startup.\n` +
|
|
236
|
+
`${existing.url}`,
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const session = await startVscode({
|
|
242
|
+
sessionKey,
|
|
243
|
+
startedBy: command.user.tag,
|
|
244
|
+
workingDirectory: resolved.workingDirectory,
|
|
245
|
+
});
|
|
246
|
+
await command.editReply({
|
|
247
|
+
content: `VS Code started for \`${session.workingDirectory}\`. ` +
|
|
248
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes, so open it before it expires.\n` +
|
|
249
|
+
`${session.url}`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
logger.error('Failed to start VS Code:', error);
|
|
254
|
+
await command.editReply({
|
|
255
|
+
content: `Failed to start VS Code: ${error instanceof Error ? error.message : String(error)}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export function cleanupAllVscodeSessions() {
|
|
260
|
+
for (const sessionKey of activeSessions.keys()) {
|
|
261
|
+
stopVscode({ sessionKey });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function onProcessExit() {
|
|
265
|
+
cleanupAllVscodeSessions();
|
|
266
|
+
}
|
|
267
|
+
process.on('SIGINT', onProcessExit);
|
|
268
|
+
process.on('SIGTERM', onProcessExit);
|
|
269
|
+
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
|
//
|
|
@@ -16,26 +15,13 @@
|
|
|
16
15
|
// Exported from kimaki-opencode-plugin.ts — each export is treated as a separate
|
|
17
16
|
// plugin by OpenCode's plugin loader.
|
|
18
17
|
import crypto from 'node:crypto';
|
|
19
|
-
import fs from 'node:fs';
|
|
20
|
-
import path from 'node:path';
|
|
21
18
|
import * as errore from 'errore';
|
|
22
19
|
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath, } from './plugin-logger.js';
|
|
23
20
|
import { setDataDir } from './config.js';
|
|
24
21
|
import { initSentry, notifyError } from './sentry.js';
|
|
25
22
|
import { execAsync } from './exec-async.js';
|
|
26
|
-
import { condenseMemoryMd } from './condense-memory.js';
|
|
27
23
|
import { ONBOARDING_TUTORIAL_INSTRUCTIONS, TUTORIAL_WELCOME_TEXT, } from './onboarding-tutorial.js';
|
|
28
24
|
const logger = createPluginLogger('OPENCODE');
|
|
29
|
-
function createSessionState() {
|
|
30
|
-
return {
|
|
31
|
-
gitState: undefined,
|
|
32
|
-
memoryInjected: false,
|
|
33
|
-
lastMemoryReminderAssistantMessageId: undefined,
|
|
34
|
-
tutorialInjected: false,
|
|
35
|
-
resolvedDirectory: undefined,
|
|
36
|
-
announcedDirectory: undefined,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
25
|
// ── Pure derivation functions ────────────────────────────────────
|
|
40
26
|
// These take state + fresh input and return whether to inject.
|
|
41
27
|
// No side effects, no mutations — easy to test with fixtures.
|
|
@@ -66,9 +52,6 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
|
66
52
|
};
|
|
67
53
|
}
|
|
68
54
|
const MEMORY_REMINDER_OUTPUT_TOKENS = 12_000;
|
|
69
|
-
function getOutputTokenTotal(tokens) {
|
|
70
|
-
return Math.max(0, tokens.output + tokens.reasoning);
|
|
71
|
-
}
|
|
72
55
|
export function shouldInjectMemoryReminderFromLatestAssistant({ lastMemoryReminderAssistantMessageId, latestAssistantMessage, threshold = MEMORY_REMINDER_OUTPUT_TOKENS, }) {
|
|
73
56
|
if (!latestAssistantMessage) {
|
|
74
57
|
return { inject: false };
|
|
@@ -85,7 +68,7 @@ export function shouldInjectMemoryReminderFromLatestAssistant({ lastMemoryRemind
|
|
|
85
68
|
if (lastMemoryReminderAssistantMessageId === latestAssistantMessage.id) {
|
|
86
69
|
return { inject: false };
|
|
87
70
|
}
|
|
88
|
-
const outputTokens =
|
|
71
|
+
const outputTokens = Math.max(0, latestAssistantMessage.tokens.output + latestAssistantMessage.tokens.reasoning);
|
|
89
72
|
if (outputTokens < threshold) {
|
|
90
73
|
return { inject: false };
|
|
91
74
|
}
|
|
@@ -184,7 +167,13 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
184
167
|
if (existing) {
|
|
185
168
|
return existing;
|
|
186
169
|
}
|
|
187
|
-
const state =
|
|
170
|
+
const state = {
|
|
171
|
+
gitState: undefined,
|
|
172
|
+
lastMemoryReminderAssistantMessageId: undefined,
|
|
173
|
+
tutorialInjected: false,
|
|
174
|
+
resolvedDirectory: undefined,
|
|
175
|
+
announcedDirectory: undefined,
|
|
176
|
+
};
|
|
188
177
|
sessions.set(sessionID, state);
|
|
189
178
|
return state;
|
|
190
179
|
}
|
|
@@ -274,25 +263,6 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
274
263
|
synthetic: true,
|
|
275
264
|
});
|
|
276
265
|
}
|
|
277
|
-
// -- MEMORY.md injection --
|
|
278
|
-
if (!state.memoryInjected) {
|
|
279
|
-
state.memoryInjected = true;
|
|
280
|
-
const memoryPath = path.join(effectiveDirectory, 'MEMORY.md');
|
|
281
|
-
const memoryContent = await fs.promises
|
|
282
|
-
.readFile(memoryPath, 'utf-8')
|
|
283
|
-
.catch(() => null);
|
|
284
|
-
if (memoryContent) {
|
|
285
|
-
const condensed = condenseMemoryMd(memoryContent);
|
|
286
|
-
output.parts.push({
|
|
287
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
288
|
-
sessionID,
|
|
289
|
-
messageID,
|
|
290
|
-
type: 'text',
|
|
291
|
-
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>`,
|
|
292
|
-
synthetic: true,
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
266
|
const memoryReminder = shouldInjectMemoryReminderFromLatestAssistant({
|
|
297
267
|
lastMemoryReminderAssistantMessageId: state.lastMemoryReminderAssistantMessageId,
|
|
298
268
|
latestAssistantMessage,
|
package/dist/db.js
CHANGED
|
@@ -201,6 +201,7 @@ async function migrateSchema(prisma) {
|
|
|
201
201
|
// Also fix NULL worktree status rows that predate the required enum.
|
|
202
202
|
const defensiveMigrations = [
|
|
203
203
|
"UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
|
|
204
|
+
"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%'",
|
|
204
205
|
"UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
|
|
205
206
|
];
|
|
206
207
|
for (const stmt of defensiveMigrations) {
|
|
@@ -365,6 +365,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
365
365
|
.setDescription(truncateCommandDescription('Stop screen sharing'))
|
|
366
366
|
.setDMPermission(false)
|
|
367
367
|
.toJSON(),
|
|
368
|
+
new SlashCommandBuilder()
|
|
369
|
+
.setName('vscode')
|
|
370
|
+
.setDescription(truncateCommandDescription('Open VS Code in the browser for this project or worktree (auto-stops after 30 minutes)'))
|
|
371
|
+
.setDMPermission(false)
|
|
372
|
+
.toJSON(),
|
|
368
373
|
];
|
|
369
374
|
// Dynamic commands are registered in priority order: agents → user commands → skills → MCP prompts.
|
|
370
375
|
// This ordering matters because we slice to MAX_DISCORD_COMMANDS (100) at the end,
|
|
@@ -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"
|
|
@@ -39,6 +39,7 @@ import { handleSessionIdCommand } from './commands/session-id.js';
|
|
|
39
39
|
import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
|
|
40
40
|
import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
41
41
|
import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
|
|
42
|
+
import { handleVscodeCommand } from './commands/vscode.js';
|
|
42
43
|
import { handleModelVariantSelectMenu } from './commands/model.js';
|
|
43
44
|
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
44
45
|
import { hasKimakiBotPermission } from './discord-utils.js';
|
|
@@ -224,6 +225,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
224
225
|
appId,
|
|
225
226
|
});
|
|
226
227
|
return;
|
|
228
|
+
case 'vscode':
|
|
229
|
+
await handleVscodeCommand({ command: interaction, appId });
|
|
230
|
+
return;
|
|
227
231
|
}
|
|
228
232
|
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
229
233
|
if (interaction.commandName.endsWith('-agent') &&
|
|
@@ -5,11 +5,13 @@
|
|
|
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
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
12
13
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
14
|
+
export { memoryOverviewPlugin } from './memory-overview-plugin.js';
|
|
13
15
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
14
16
|
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
|
|
15
17
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import * as errore from 'errore';
|
|
9
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath, } from './plugin-logger.js';
|
|
10
|
+
import { condenseMemoryMd } from './condense-memory.js';
|
|
11
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
12
|
+
const logger = createPluginLogger('OPENCODE');
|
|
13
|
+
function createSessionState() {
|
|
14
|
+
return {
|
|
15
|
+
hasFrozenOverview: false,
|
|
16
|
+
frozenOverviewText: null,
|
|
17
|
+
injected: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function buildMemoryOverviewReminder({ condensed }) {
|
|
21
|
+
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>`;
|
|
22
|
+
}
|
|
23
|
+
async function freezeMemoryOverview({ directory, state, }) {
|
|
24
|
+
if (state.hasFrozenOverview) {
|
|
25
|
+
return state.frozenOverviewText;
|
|
26
|
+
}
|
|
27
|
+
const memoryPath = path.join(directory, 'MEMORY.md');
|
|
28
|
+
const memoryContentResult = await fs.promises.readFile(memoryPath, 'utf-8').catch(() => {
|
|
29
|
+
return null;
|
|
30
|
+
});
|
|
31
|
+
if (!memoryContentResult) {
|
|
32
|
+
state.hasFrozenOverview = true;
|
|
33
|
+
state.frozenOverviewText = null;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const condensed = condenseMemoryMd(memoryContentResult);
|
|
37
|
+
state.hasFrozenOverview = true;
|
|
38
|
+
state.frozenOverviewText = buildMemoryOverviewReminder({ condensed });
|
|
39
|
+
return state.frozenOverviewText;
|
|
40
|
+
}
|
|
41
|
+
const memoryOverviewPlugin = async ({ directory }) => {
|
|
42
|
+
initSentry();
|
|
43
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
44
|
+
if (dataDir) {
|
|
45
|
+
setPluginLogFilePath(dataDir);
|
|
46
|
+
}
|
|
47
|
+
const sessions = new Map();
|
|
48
|
+
function getOrCreateSessionState({ sessionID }) {
|
|
49
|
+
const existing = sessions.get(sessionID);
|
|
50
|
+
if (existing) {
|
|
51
|
+
return existing;
|
|
52
|
+
}
|
|
53
|
+
const state = createSessionState();
|
|
54
|
+
sessions.set(sessionID, state);
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
'chat.message': async (input, output) => {
|
|
59
|
+
const result = await errore.tryAsync({
|
|
60
|
+
try: async () => {
|
|
61
|
+
const state = getOrCreateSessionState({ sessionID: input.sessionID });
|
|
62
|
+
if (state.injected) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const firstPart = output.parts.find((part) => {
|
|
66
|
+
if (part.type !== 'text') {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return part.synthetic !== true;
|
|
70
|
+
});
|
|
71
|
+
if (!firstPart || firstPart.type !== 'text' || firstPart.text.trim().length === 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const overviewText = await freezeMemoryOverview({ directory, state });
|
|
75
|
+
state.injected = true;
|
|
76
|
+
if (!overviewText) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
output.parts.push({
|
|
80
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
81
|
+
sessionID: input.sessionID,
|
|
82
|
+
messageID: firstPart.messageID,
|
|
83
|
+
type: 'text',
|
|
84
|
+
text: overviewText,
|
|
85
|
+
synthetic: true,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
catch: (error) => {
|
|
89
|
+
return new Error('memory overview chat.message hook failed', {
|
|
90
|
+
cause: error,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (!(result instanceof Error)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
logger.warn(`[memory-overview-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
98
|
+
void notifyError(result, 'memory overview plugin chat.message hook failed');
|
|
99
|
+
},
|
|
100
|
+
event: async ({ event }) => {
|
|
101
|
+
const result = await errore.tryAsync({
|
|
102
|
+
try: async () => {
|
|
103
|
+
if (event.type !== 'session.deleted') {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const id = event.properties?.info?.id;
|
|
107
|
+
if (!id) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
sessions.delete(id);
|
|
111
|
+
},
|
|
112
|
+
catch: (error) => {
|
|
113
|
+
return new Error('memory overview event hook failed', {
|
|
114
|
+
cause: error,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
if (!(result instanceof Error)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
logger.warn(`[memory-overview-plugin] ${formatPluginErrorWithStack(result)}`);
|
|
122
|
+
void notifyError(result, 'memory overview plugin event hook failed');
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
export { memoryOverviewPlugin };
|