kimaki 0.1.0 → 0.1.3
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/bin.js +65 -0
- package/dist/cli.js +34 -29
- package/dist/discordBot.js +61 -39
- package/dist/markdown.js +6 -2
- package/dist/tools.js +15 -14
- package/dist/utils.js +10 -21
- package/dist/xml.js +4 -0
- package/dist/xml.test.js +32 -0
- package/package.json +2 -2
- package/src/cli.ts +48 -36
- package/src/discordBot.ts +14 -8
- package/src/markdown.ts +6 -2
- package/src/tools.ts +16 -15
- package/src/utils.ts +10 -21
- package/src/xml.test.ts +37 -0
- package/src/xml.ts +5 -0
- package/bin.sh +0 -28
package/bin.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
7
|
+
const __dirname = dirname(__filename)
|
|
8
|
+
|
|
9
|
+
const NODE_PATH = process.execPath
|
|
10
|
+
const CLI_PATH = join(__dirname, 'dist', 'cli.js')
|
|
11
|
+
|
|
12
|
+
let lastStart = 0
|
|
13
|
+
|
|
14
|
+
async function sleep(ms) {
|
|
15
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function run() {
|
|
19
|
+
while (true) {
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
const elapsed = now - lastStart
|
|
22
|
+
if (elapsed < 5000) {
|
|
23
|
+
await sleep(5000 - elapsed)
|
|
24
|
+
}
|
|
25
|
+
lastStart = Date.now()
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const code = await new Promise((resolve) => {
|
|
29
|
+
const child = spawn(NODE_PATH, [CLI_PATH, ...process.argv.slice(2)], {
|
|
30
|
+
stdio: 'inherit'
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
child.on('exit', (code, signal) => {
|
|
34
|
+
if (signal) {
|
|
35
|
+
// Map signals to exit codes similar to bash
|
|
36
|
+
if (signal === 'SIGINT') resolve(130)
|
|
37
|
+
else if (signal === 'SIGTERM') resolve(143)
|
|
38
|
+
else resolve(1)
|
|
39
|
+
} else {
|
|
40
|
+
resolve(code || 0)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
child.on('error', (err) => {
|
|
45
|
+
console.error('Failed to start process:', err)
|
|
46
|
+
resolve(1)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Exit cleanly if the app ended OK or via SIGINT/SIGTERM
|
|
51
|
+
if (code === 0 || code === 130 || code === 143 || code === 64) {
|
|
52
|
+
process.exit(code)
|
|
53
|
+
}
|
|
54
|
+
// otherwise loop; the 5s throttle above will apply
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('Unexpected error:', err)
|
|
57
|
+
// Continue looping after error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
run().catch(err => {
|
|
63
|
+
console.error('Fatal error:', err)
|
|
64
|
+
process.exit(1)
|
|
65
|
+
})
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cac } from 'cac';
|
|
3
|
-
import { intro, outro, text, password, note, cancel, isCancel, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
|
-
import { generateBotInstallUrl } from './utils.js';
|
|
3
|
+
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
|
+
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
5
5
|
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, } from './discordBot.js';
|
|
6
6
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
@@ -74,7 +74,6 @@ async function ensureKimakiCategory(guild) {
|
|
|
74
74
|
}
|
|
75
75
|
async function run({ restart, addChannels }) {
|
|
76
76
|
const forceSetup = Boolean(restart);
|
|
77
|
-
const shouldAddChannels = Boolean(addChannels);
|
|
78
77
|
intro('🤖 Discord Bot Setup');
|
|
79
78
|
const db = getDatabase();
|
|
80
79
|
let appId;
|
|
@@ -82,6 +81,7 @@ async function run({ restart, addChannels }) {
|
|
|
82
81
|
const existingBot = db
|
|
83
82
|
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
84
83
|
.get();
|
|
84
|
+
const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels);
|
|
85
85
|
if (existingBot && !forceSetup) {
|
|
86
86
|
appId = existingBot.app_id;
|
|
87
87
|
token = existingBot.token;
|
|
@@ -112,8 +112,22 @@ async function run({ restart, addChannels }) {
|
|
|
112
112
|
}
|
|
113
113
|
appId = appIdInput;
|
|
114
114
|
note('1. Go to the "Bot" section in the left sidebar\n' +
|
|
115
|
-
'2.
|
|
116
|
-
|
|
115
|
+
'2. Scroll down to "Privileged Gateway Intents"\n' +
|
|
116
|
+
'3. Enable these intents by toggling them ON:\n' +
|
|
117
|
+
' • SERVER MEMBERS INTENT\n' +
|
|
118
|
+
' • MESSAGE CONTENT INTENT\n' +
|
|
119
|
+
'4. Click "Save Changes" at the bottom', 'Step 2: Enable Required Intents');
|
|
120
|
+
const intentsConfirmed = await text({
|
|
121
|
+
message: 'Press Enter after enabling both intents:',
|
|
122
|
+
placeholder: 'Enter',
|
|
123
|
+
});
|
|
124
|
+
if (isCancel(intentsConfirmed)) {
|
|
125
|
+
cancel('Setup cancelled');
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
note('1. Still in the "Bot" section\n' +
|
|
129
|
+
'2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
|
|
130
|
+
"3. Copy the token (you won't be able to see it again!)", 'Step 3: Get Bot Token');
|
|
117
131
|
const tokenInput = await password({
|
|
118
132
|
message: 'Enter your Discord Bot Token (will be hidden):',
|
|
119
133
|
validate(value) {
|
|
@@ -128,15 +142,10 @@ async function run({ restart, addChannels }) {
|
|
|
128
142
|
process.exit(0);
|
|
129
143
|
}
|
|
130
144
|
token = tokenInput;
|
|
131
|
-
|
|
132
|
-
note('Token saved to database', 'Credentials Stored');
|
|
133
|
-
note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 3: Install Bot to Server');
|
|
145
|
+
note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
|
|
134
146
|
const installed = await text({
|
|
135
147
|
message: 'Press Enter AFTER you have installed the bot in your server:',
|
|
136
|
-
placeholder: '
|
|
137
|
-
validate() {
|
|
138
|
-
return undefined;
|
|
139
|
-
},
|
|
148
|
+
placeholder: 'Enter',
|
|
140
149
|
});
|
|
141
150
|
if (isCancel(installed)) {
|
|
142
151
|
cancel('Setup cancelled');
|
|
@@ -172,6 +181,7 @@ async function run({ restart, addChannels }) {
|
|
|
172
181
|
cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
|
|
173
182
|
process.exit(EXIT_NO_RESTART);
|
|
174
183
|
}
|
|
184
|
+
db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
|
|
175
185
|
for (const { guild, channels } of kimakiChannels) {
|
|
176
186
|
for (const channel of channels) {
|
|
177
187
|
if (channel.kimakiDirectory) {
|
|
@@ -197,22 +207,13 @@ async function run({ restart, addChannels }) {
|
|
|
197
207
|
note(channelList, 'Existing Kimaki Channels');
|
|
198
208
|
}
|
|
199
209
|
s.start('Starting OpenCode server...');
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
client = await initializeOpencodeForDirectory(currentDir);
|
|
204
|
-
s.stop('OpenCode server started!');
|
|
205
|
-
}
|
|
206
|
-
catch (error) {
|
|
207
|
-
s.stop('Failed to start OpenCode');
|
|
208
|
-
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
209
|
-
discordClient.destroy();
|
|
210
|
-
process.exit(EXIT_NO_RESTART);
|
|
211
|
-
}
|
|
210
|
+
const currentDir = process.cwd();
|
|
211
|
+
let getClient = await initializeOpencodeForDirectory(currentDir);
|
|
212
|
+
s.stop('OpenCode server started!');
|
|
212
213
|
s.start('Fetching OpenCode projects...');
|
|
213
214
|
let projects = [];
|
|
214
215
|
try {
|
|
215
|
-
const projectsResponse = await
|
|
216
|
+
const projectsResponse = await getClient().project.list();
|
|
216
217
|
if (!projectsResponse.data) {
|
|
217
218
|
throw new Error('Failed to fetch projects');
|
|
218
219
|
}
|
|
@@ -225,12 +226,16 @@ async function run({ restart, addChannels }) {
|
|
|
225
226
|
discordClient.destroy();
|
|
226
227
|
process.exit(EXIT_NO_RESTART);
|
|
227
228
|
}
|
|
228
|
-
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
229
|
-
|
|
229
|
+
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
230
|
+
.filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
|
|
231
|
+
.map((ch) => ch.kimakiDirectory)
|
|
232
|
+
.filter(Boolean));
|
|
233
|
+
const availableProjects = deduplicateByKey(projects.filter((project) => !existingDirs.includes(project.worktree)), (x) => x.worktree);
|
|
230
234
|
if (availableProjects.length === 0) {
|
|
231
235
|
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
232
236
|
}
|
|
233
|
-
if (
|
|
237
|
+
if ((!existingDirs?.length && availableProjects.length > 0) ||
|
|
238
|
+
shouldAddChannels) {
|
|
234
239
|
const selectedProjects = await multiselect({
|
|
235
240
|
message: 'Select projects to create Discord channels for:',
|
|
236
241
|
options: availableProjects.map((project) => ({
|
|
@@ -271,7 +276,7 @@ async function run({ restart, addChannels }) {
|
|
|
271
276
|
if (!project)
|
|
272
277
|
continue;
|
|
273
278
|
const baseName = path.basename(project.worktree);
|
|
274
|
-
const channelName =
|
|
279
|
+
const channelName = `${baseName}`
|
|
275
280
|
.toLowerCase()
|
|
276
281
|
.replace(/[^a-z0-9-]/g, '-')
|
|
277
282
|
.slice(0, 100);
|
package/dist/discordBot.js
CHANGED
|
@@ -14,7 +14,7 @@ import { PassThrough, Transform } from 'node:stream';
|
|
|
14
14
|
import * as prism from 'prism-media';
|
|
15
15
|
import dedent from 'string-dedent';
|
|
16
16
|
import { transcribeAudio } from './voice.js';
|
|
17
|
-
import { extractTagsArrays } from './xml.js';
|
|
17
|
+
import { extractTagsArrays, extractNonXmlContent } from './xml.js';
|
|
18
18
|
import prettyMilliseconds from 'pretty-ms';
|
|
19
19
|
import { createLogger } from './logger.js';
|
|
20
20
|
const discordLogger = createLogger('DISCORD');
|
|
@@ -28,6 +28,8 @@ const opencodeServers = new Map();
|
|
|
28
28
|
const abortControllers = new Map();
|
|
29
29
|
// Map of guild ID to voice connection and GenAI worker
|
|
30
30
|
const voiceConnections = new Map();
|
|
31
|
+
// Map of directory to retry count for server restarts
|
|
32
|
+
const serverRetryCount = new Map();
|
|
31
33
|
let db = null;
|
|
32
34
|
function convertToMono16k(buffer) {
|
|
33
35
|
// Parameters
|
|
@@ -225,14 +227,16 @@ async function setupVoiceHandling({ connection, guildId, channelId, }) {
|
|
|
225
227
|
.on('data', (frame) => {
|
|
226
228
|
// Check if a newer speaking session has started
|
|
227
229
|
if (currentSessionCount !== speakingSessionCount) {
|
|
228
|
-
voiceLogger.log(
|
|
230
|
+
// voiceLogger.log(
|
|
231
|
+
// `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
|
|
232
|
+
// )
|
|
229
233
|
return;
|
|
230
234
|
}
|
|
231
235
|
if (!voiceData.genAiWorker) {
|
|
232
236
|
voiceLogger.warn(`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`);
|
|
233
237
|
return;
|
|
234
238
|
}
|
|
235
|
-
voiceLogger.debug('User audio chunk length', frame.length)
|
|
239
|
+
// voiceLogger.debug('User audio chunk length', frame.length)
|
|
236
240
|
// Write to PCM file if stream exists
|
|
237
241
|
voiceData.userAudioStream?.write(frame);
|
|
238
242
|
// stream incrementally — low latency
|
|
@@ -551,29 +555,19 @@ function getKimakiMetadata(textChannel) {
|
|
|
551
555
|
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
552
556
|
return { projectDirectory, channelAppId };
|
|
553
557
|
}
|
|
554
|
-
function getFileAutocompleteState(value) {
|
|
555
|
-
const input = value ?? '';
|
|
556
|
-
const match = input.match(/([^,\s]*)$/);
|
|
557
|
-
const token = match ? match[1] || '' : '';
|
|
558
|
-
const prefix = input.slice(0, input.length - token.length);
|
|
559
|
-
const parts = input
|
|
560
|
-
.split(/[\s,]+/)
|
|
561
|
-
.map((part) => part.trim())
|
|
562
|
-
.filter(Boolean);
|
|
563
|
-
const selected = new Set(parts);
|
|
564
|
-
const trimmedToken = token.trim();
|
|
565
|
-
if (trimmedToken) {
|
|
566
|
-
selected.delete(trimmedToken);
|
|
567
|
-
}
|
|
568
|
-
return { prefix, token, selected };
|
|
569
|
-
}
|
|
570
558
|
export async function initializeOpencodeForDirectory(directory) {
|
|
571
559
|
// console.log(`[OPENCODE] Initializing for directory: ${directory}`)
|
|
572
560
|
// Check if we already have a server for this directory
|
|
573
561
|
const existing = opencodeServers.get(directory);
|
|
574
562
|
if (existing && !existing.process.killed) {
|
|
575
563
|
opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
|
|
576
|
-
return
|
|
564
|
+
return () => {
|
|
565
|
+
const entry = opencodeServers.get(directory);
|
|
566
|
+
if (!entry?.client) {
|
|
567
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
568
|
+
}
|
|
569
|
+
return entry.client;
|
|
570
|
+
};
|
|
577
571
|
}
|
|
578
572
|
const port = await getOpenPort();
|
|
579
573
|
// console.log(
|
|
@@ -589,17 +583,34 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
589
583
|
},
|
|
590
584
|
});
|
|
591
585
|
serverProcess.stdout?.on('data', (data) => {
|
|
592
|
-
opencodeLogger.log(`
|
|
586
|
+
opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
|
|
593
587
|
});
|
|
594
588
|
serverProcess.stderr?.on('data', (data) => {
|
|
595
|
-
opencodeLogger.error(`
|
|
589
|
+
opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
|
|
596
590
|
});
|
|
597
591
|
serverProcess.on('error', (error) => {
|
|
598
|
-
opencodeLogger.error(`Failed to start server on port :`, error);
|
|
592
|
+
opencodeLogger.error(`Failed to start server on port :`, port, error);
|
|
599
593
|
});
|
|
600
594
|
serverProcess.on('exit', (code) => {
|
|
601
|
-
opencodeLogger.log(`
|
|
595
|
+
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
602
596
|
opencodeServers.delete(directory);
|
|
597
|
+
if (code !== 0) {
|
|
598
|
+
const retryCount = serverRetryCount.get(directory) || 0;
|
|
599
|
+
if (retryCount < 5) {
|
|
600
|
+
serverRetryCount.set(directory, retryCount + 1);
|
|
601
|
+
opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
|
|
602
|
+
initializeOpencodeForDirectory(directory).catch((e) => {
|
|
603
|
+
opencodeLogger.error(`Failed to restart opencode server:`, e);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
// Reset retry count on clean exit
|
|
612
|
+
serverRetryCount.delete(directory);
|
|
613
|
+
}
|
|
603
614
|
});
|
|
604
615
|
await waitForServer(port);
|
|
605
616
|
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
|
|
@@ -608,7 +619,13 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
608
619
|
client,
|
|
609
620
|
port,
|
|
610
621
|
});
|
|
611
|
-
return
|
|
622
|
+
return () => {
|
|
623
|
+
const entry = opencodeServers.get(directory);
|
|
624
|
+
if (!entry?.client) {
|
|
625
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
626
|
+
}
|
|
627
|
+
return entry.client;
|
|
628
|
+
};
|
|
612
629
|
}
|
|
613
630
|
function formatPart(part) {
|
|
614
631
|
switch (part.type) {
|
|
@@ -746,7 +763,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
746
763
|
const directory = projectDirectory || process.cwd();
|
|
747
764
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
748
765
|
// Note: We'll cancel the existing request after we have the session ID
|
|
749
|
-
const
|
|
766
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
750
767
|
// Get session ID from database
|
|
751
768
|
const row = getDatabase()
|
|
752
769
|
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
@@ -756,7 +773,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
756
773
|
if (sessionId) {
|
|
757
774
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
|
|
758
775
|
try {
|
|
759
|
-
const sessionResponse = await
|
|
776
|
+
const sessionResponse = await getClient().session.get({
|
|
760
777
|
path: { id: sessionId },
|
|
761
778
|
});
|
|
762
779
|
session = sessionResponse.data;
|
|
@@ -768,7 +785,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
768
785
|
}
|
|
769
786
|
if (!session) {
|
|
770
787
|
voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
|
|
771
|
-
const sessionResponse = await
|
|
788
|
+
const sessionResponse = await getClient().session.create({
|
|
772
789
|
body: { title: prompt.slice(0, 80) },
|
|
773
790
|
});
|
|
774
791
|
session = sessionResponse.data;
|
|
@@ -794,7 +811,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
794
811
|
const abortController = new AbortController();
|
|
795
812
|
// Store this controller for this session
|
|
796
813
|
abortControllers.set(session.id, abortController);
|
|
797
|
-
const eventsResult = await
|
|
814
|
+
const eventsResult = await getClient().event.subscribe({
|
|
798
815
|
signal: abortController.signal,
|
|
799
816
|
});
|
|
800
817
|
const events = eventsResult.stream;
|
|
@@ -1034,7 +1051,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1034
1051
|
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1035
1052
|
// Start the event handler
|
|
1036
1053
|
const eventHandlerPromise = eventHandler();
|
|
1037
|
-
const response = await
|
|
1054
|
+
const response = await getClient().session.prompt({
|
|
1038
1055
|
path: { id: session.id },
|
|
1039
1056
|
body: {
|
|
1040
1057
|
parts: [{ type: 'text', text: prompt }],
|
|
@@ -1322,9 +1339,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1322
1339
|
}
|
|
1323
1340
|
try {
|
|
1324
1341
|
// Get OpenCode client for this directory
|
|
1325
|
-
const
|
|
1342
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
1326
1343
|
// List sessions
|
|
1327
|
-
const sessionsResponse = await
|
|
1344
|
+
const sessionsResponse = await getClient().session.list();
|
|
1328
1345
|
if (!sessionsResponse.data) {
|
|
1329
1346
|
await interaction.respond([]);
|
|
1330
1347
|
return;
|
|
@@ -1385,9 +1402,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1385
1402
|
}
|
|
1386
1403
|
try {
|
|
1387
1404
|
// Initialize OpenCode client for the directory
|
|
1388
|
-
const
|
|
1405
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
1389
1406
|
// Get session title
|
|
1390
|
-
const sessionResponse = await
|
|
1407
|
+
const sessionResponse = await getClient().session.get({
|
|
1391
1408
|
path: { id: sessionId },
|
|
1392
1409
|
});
|
|
1393
1410
|
if (!sessionResponse.data) {
|
|
@@ -1407,7 +1424,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1407
1424
|
.run(thread.id, sessionId);
|
|
1408
1425
|
voiceLogger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`);
|
|
1409
1426
|
// Fetch all messages for the session
|
|
1410
|
-
const messagesResponse = await
|
|
1427
|
+
const messagesResponse = await getClient().session.messages({
|
|
1411
1428
|
path: { id: sessionId },
|
|
1412
1429
|
});
|
|
1413
1430
|
if (!messagesResponse.data) {
|
|
@@ -1423,10 +1440,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1423
1440
|
if (message.info.role === 'user') {
|
|
1424
1441
|
// Render user messages
|
|
1425
1442
|
const userParts = message.parts.filter((p) => p.type === 'text');
|
|
1426
|
-
const
|
|
1427
|
-
.map((p) =>
|
|
1428
|
-
|
|
1429
|
-
|
|
1443
|
+
const userTexts = userParts
|
|
1444
|
+
.map((p) => {
|
|
1445
|
+
if (typeof p.text === 'string') {
|
|
1446
|
+
return extractNonXmlContent(p.text);
|
|
1447
|
+
}
|
|
1448
|
+
return '';
|
|
1449
|
+
})
|
|
1450
|
+
.filter((t) => t.trim());
|
|
1451
|
+
const userText = userTexts.join('\n\n');
|
|
1430
1452
|
if (userText) {
|
|
1431
1453
|
// Escape backticks in user messages to prevent formatting issues
|
|
1432
1454
|
const escapedText = escapeDiscordFormatting(userText);
|
package/dist/markdown.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { format } from 'date-fns';
|
|
2
2
|
import * as yaml from 'js-yaml';
|
|
3
|
+
import { extractNonXmlContent } from './xml.js';
|
|
3
4
|
export class ShareMarkdown {
|
|
4
5
|
client;
|
|
5
6
|
constructor(client) {
|
|
@@ -73,8 +74,11 @@ export class ShareMarkdown {
|
|
|
73
74
|
lines.push('');
|
|
74
75
|
for (const part of parts) {
|
|
75
76
|
if (part.type === 'text' && part.text) {
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
const cleanedText = extractNonXmlContent(part.text);
|
|
78
|
+
if (cleanedText.trim()) {
|
|
79
|
+
lines.push(cleanedText);
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
78
82
|
}
|
|
79
83
|
else if (part.type === 'file') {
|
|
80
84
|
lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`);
|
package/dist/tools.js
CHANGED
|
@@ -10,13 +10,14 @@ import { ShareMarkdown } from './markdown.js';
|
|
|
10
10
|
import pc from 'picocolors';
|
|
11
11
|
import { initializeOpencodeForDirectory } from './discordBot.js';
|
|
12
12
|
export async function getTools({ onMessageCompleted, directory, }) {
|
|
13
|
-
const
|
|
13
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
14
|
+
const client = getClient();
|
|
14
15
|
const markdownRenderer = new ShareMarkdown(client);
|
|
15
16
|
const providersResponse = await client.config.providers({});
|
|
16
17
|
const providers = providersResponse.data?.providers || [];
|
|
17
18
|
// Helper: get last assistant model for a session (non-summary)
|
|
18
19
|
const getSessionModel = async (sessionId) => {
|
|
19
|
-
const res = await
|
|
20
|
+
const res = await getClient().session.messages({ path: { id: sessionId } });
|
|
20
21
|
const data = res.data;
|
|
21
22
|
if (!data || data.length === 0)
|
|
22
23
|
return undefined;
|
|
@@ -41,8 +42,8 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
41
42
|
execute: async ({ sessionId, message }) => {
|
|
42
43
|
const sessionModel = await getSessionModel(sessionId);
|
|
43
44
|
// do not await
|
|
44
|
-
|
|
45
|
-
.prompt({
|
|
45
|
+
getClient()
|
|
46
|
+
.session.prompt({
|
|
46
47
|
path: { id: sessionId },
|
|
47
48
|
body: {
|
|
48
49
|
parts: [{ type: 'text', text: message }],
|
|
@@ -99,7 +100,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
99
100
|
throw new Error(`message must be a non empty string`);
|
|
100
101
|
}
|
|
101
102
|
try {
|
|
102
|
-
const session = await
|
|
103
|
+
const session = await getClient().session.create({
|
|
103
104
|
body: {
|
|
104
105
|
title: title || message.slice(0, 50),
|
|
105
106
|
},
|
|
@@ -108,8 +109,8 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
108
109
|
throw new Error('Failed to create session');
|
|
109
110
|
}
|
|
110
111
|
// do not await
|
|
111
|
-
|
|
112
|
-
.prompt({
|
|
112
|
+
getClient()
|
|
113
|
+
.session.prompt({
|
|
113
114
|
path: { id: session.data.id },
|
|
114
115
|
body: {
|
|
115
116
|
parts: [{ type: 'text', text: message }],
|
|
@@ -155,7 +156,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
155
156
|
inputSchema: z.object({}),
|
|
156
157
|
execute: async () => {
|
|
157
158
|
toolsLogger.log(`Listing opencode sessions`);
|
|
158
|
-
const sessions = await
|
|
159
|
+
const sessions = await getClient().session.list();
|
|
159
160
|
if (!sessions.data) {
|
|
160
161
|
return { success: false, error: 'No sessions found' };
|
|
161
162
|
}
|
|
@@ -169,7 +170,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
169
170
|
const status = await (async () => {
|
|
170
171
|
if (session.revert)
|
|
171
172
|
return 'error';
|
|
172
|
-
const messagesResponse = await
|
|
173
|
+
const messagesResponse = await getClient().session.messages({
|
|
173
174
|
path: { id: session.id },
|
|
174
175
|
});
|
|
175
176
|
const messages = messagesResponse.data || [];
|
|
@@ -208,7 +209,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
208
209
|
query: z.string().describe('The search query for files'),
|
|
209
210
|
}),
|
|
210
211
|
execute: async ({ folder, query }) => {
|
|
211
|
-
const results = await
|
|
212
|
+
const results = await getClient().find.files({
|
|
212
213
|
query: {
|
|
213
214
|
query,
|
|
214
215
|
directory: folder,
|
|
@@ -231,7 +232,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
231
232
|
}),
|
|
232
233
|
execute: async ({ sessionId, lastAssistantOnly = false }) => {
|
|
233
234
|
if (lastAssistantOnly) {
|
|
234
|
-
const messages = await
|
|
235
|
+
const messages = await getClient().session.messages({
|
|
235
236
|
path: { id: sessionId },
|
|
236
237
|
});
|
|
237
238
|
if (!messages.data) {
|
|
@@ -263,7 +264,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
263
264
|
const markdown = await markdownRenderer.generate({
|
|
264
265
|
sessionID: sessionId,
|
|
265
266
|
});
|
|
266
|
-
const messages = await
|
|
267
|
+
const messages = await getClient().session.messages({
|
|
267
268
|
path: { id: sessionId },
|
|
268
269
|
});
|
|
269
270
|
const lastMessage = messages.data?.[messages.data.length - 1];
|
|
@@ -288,7 +289,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
288
289
|
}),
|
|
289
290
|
execute: async ({ sessionId }) => {
|
|
290
291
|
try {
|
|
291
|
-
const result = await
|
|
292
|
+
const result = await getClient().session.abort({
|
|
292
293
|
path: { id: sessionId },
|
|
293
294
|
});
|
|
294
295
|
if (!result.data) {
|
|
@@ -316,7 +317,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
316
317
|
inputSchema: z.object({}),
|
|
317
318
|
execute: async () => {
|
|
318
319
|
try {
|
|
319
|
-
const providersResponse = await
|
|
320
|
+
const providersResponse = await getClient().config.providers({});
|
|
320
321
|
const providers = providersResponse.data?.providers || [];
|
|
321
322
|
const models = [];
|
|
322
323
|
providers.forEach((provider) => {
|
package/dist/utils.js
CHANGED
|
@@ -28,25 +28,14 @@ export function generateBotInstallUrl({ clientId, permissions = [
|
|
|
28
28
|
}
|
|
29
29
|
return url.toString();
|
|
30
30
|
}
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
PermissionsBitField.Flags.ManageMessages,
|
|
42
|
-
PermissionsBitField.Flags.UseExternalEmojis,
|
|
43
|
-
PermissionsBitField.Flags.AttachFiles,
|
|
44
|
-
PermissionsBitField.Flags.Connect,
|
|
45
|
-
PermissionsBitField.Flags.Speak,
|
|
46
|
-
];
|
|
47
|
-
}
|
|
48
|
-
function getPermissionNames() {
|
|
49
|
-
const permissions = getRequiredBotPermissions();
|
|
50
|
-
const permissionsBitField = new PermissionsBitField(permissions);
|
|
51
|
-
return permissionsBitField.toArray();
|
|
31
|
+
export function deduplicateByKey(arr, keyFn) {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
return arr.filter(item => {
|
|
34
|
+
const key = keyFn(item);
|
|
35
|
+
if (seen.has(key)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
seen.add(key);
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
52
41
|
}
|
package/dist/xml.js
CHANGED
package/dist/xml.test.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { extractNonXmlContent } from './xml.js';
|
|
3
|
+
describe('extractNonXmlContent', () => {
|
|
4
|
+
test('removes xml tags and returns only text content', () => {
|
|
5
|
+
const xml = 'Hello <tag>content</tag> world <nested><inner>deep</inner></nested> end';
|
|
6
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
|
|
7
|
+
"Hello
|
|
8
|
+
world
|
|
9
|
+
end"
|
|
10
|
+
`);
|
|
11
|
+
});
|
|
12
|
+
test('handles multiple text segments', () => {
|
|
13
|
+
const xml = 'Start <a>tag1</a> middle <b>tag2</b> finish';
|
|
14
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
|
|
15
|
+
"Start
|
|
16
|
+
middle
|
|
17
|
+
finish"
|
|
18
|
+
`);
|
|
19
|
+
});
|
|
20
|
+
test('handles only xml without text', () => {
|
|
21
|
+
const xml = '<root><child>content</child></root>';
|
|
22
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
|
|
23
|
+
});
|
|
24
|
+
test('handles only text without xml', () => {
|
|
25
|
+
const xml = 'Just plain text';
|
|
26
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`"Just plain text"`);
|
|
27
|
+
});
|
|
28
|
+
test('handles empty string', () => {
|
|
29
|
+
const xml = '';
|
|
30
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
note,
|
|
9
9
|
cancel,
|
|
10
10
|
isCancel,
|
|
11
|
+
confirm,
|
|
11
12
|
log,
|
|
12
13
|
multiselect,
|
|
13
14
|
spinner,
|
|
14
15
|
} from '@clack/prompts'
|
|
15
|
-
import { generateBotInstallUrl } from './utils.js'
|
|
16
|
+
import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
|
|
16
17
|
import {
|
|
17
18
|
getChannelsWithDescriptions,
|
|
18
19
|
createDiscordClient,
|
|
@@ -138,7 +139,6 @@ async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
|
|
|
138
139
|
|
|
139
140
|
async function run({ restart, addChannels }: CliOptions) {
|
|
140
141
|
const forceSetup = Boolean(restart)
|
|
141
|
-
const shouldAddChannels = Boolean(addChannels)
|
|
142
142
|
|
|
143
143
|
intro('🤖 Discord Bot Setup')
|
|
144
144
|
|
|
@@ -152,6 +152,9 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
152
152
|
)
|
|
153
153
|
.get() as { app_id: string; token: string } | undefined
|
|
154
154
|
|
|
155
|
+
const shouldAddChannels =
|
|
156
|
+
!existingBot?.token || forceSetup || Boolean(addChannels)
|
|
157
|
+
|
|
155
158
|
if (existingBot && !forceSetup) {
|
|
156
159
|
appId = existingBot.app_id
|
|
157
160
|
token = existingBot.token
|
|
@@ -196,9 +199,29 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
196
199
|
|
|
197
200
|
note(
|
|
198
201
|
'1. Go to the "Bot" section in the left sidebar\n' +
|
|
199
|
-
'2.
|
|
202
|
+
'2. Scroll down to "Privileged Gateway Intents"\n' +
|
|
203
|
+
'3. Enable these intents by toggling them ON:\n' +
|
|
204
|
+
' • SERVER MEMBERS INTENT\n' +
|
|
205
|
+
' • MESSAGE CONTENT INTENT\n' +
|
|
206
|
+
'4. Click "Save Changes" at the bottom',
|
|
207
|
+
'Step 2: Enable Required Intents',
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const intentsConfirmed = await text({
|
|
211
|
+
message: 'Press Enter after enabling both intents:',
|
|
212
|
+
placeholder: 'Enter',
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if (isCancel(intentsConfirmed)) {
|
|
216
|
+
cancel('Setup cancelled')
|
|
217
|
+
process.exit(0)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
note(
|
|
221
|
+
'1. Still in the "Bot" section\n' +
|
|
222
|
+
'2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
|
|
200
223
|
"3. Copy the token (you won't be able to see it again!)",
|
|
201
|
-
'Step
|
|
224
|
+
'Step 3: Get Bot Token',
|
|
202
225
|
)
|
|
203
226
|
|
|
204
227
|
const tokenInput = await password({
|
|
@@ -215,23 +238,14 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
215
238
|
}
|
|
216
239
|
token = tokenInput
|
|
217
240
|
|
|
218
|
-
db.prepare(
|
|
219
|
-
'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
|
|
220
|
-
).run(appId, token)
|
|
221
|
-
|
|
222
|
-
note('Token saved to database', 'Credentials Stored')
|
|
223
|
-
|
|
224
241
|
note(
|
|
225
242
|
`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`,
|
|
226
|
-
'Step
|
|
243
|
+
'Step 4: Install Bot to Server',
|
|
227
244
|
)
|
|
228
245
|
|
|
229
246
|
const installed = await text({
|
|
230
247
|
message: 'Press Enter AFTER you have installed the bot in your server:',
|
|
231
|
-
placeholder: '
|
|
232
|
-
validate() {
|
|
233
|
-
return undefined
|
|
234
|
-
},
|
|
248
|
+
placeholder: 'Enter',
|
|
235
249
|
})
|
|
236
250
|
|
|
237
251
|
if (isCancel(installed)) {
|
|
@@ -282,6 +296,9 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
282
296
|
)
|
|
283
297
|
process.exit(EXIT_NO_RESTART)
|
|
284
298
|
}
|
|
299
|
+
db.prepare(
|
|
300
|
+
'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
|
|
301
|
+
).run(appId, token)
|
|
285
302
|
|
|
286
303
|
for (const { guild, channels } of kimakiChannels) {
|
|
287
304
|
for (const channel of channels) {
|
|
@@ -324,28 +341,16 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
324
341
|
|
|
325
342
|
s.start('Starting OpenCode server...')
|
|
326
343
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const currentDir = process.cwd()
|
|
331
|
-
client = await initializeOpencodeForDirectory(currentDir)
|
|
332
|
-
s.stop('OpenCode server started!')
|
|
333
|
-
} catch (error) {
|
|
334
|
-
s.stop('Failed to start OpenCode')
|
|
335
|
-
cliLogger.error(
|
|
336
|
-
'Error:',
|
|
337
|
-
error instanceof Error ? error.message : String(error),
|
|
338
|
-
)
|
|
339
|
-
discordClient.destroy()
|
|
340
|
-
process.exit(EXIT_NO_RESTART)
|
|
341
|
-
}
|
|
344
|
+
const currentDir = process.cwd()
|
|
345
|
+
let getClient = await initializeOpencodeForDirectory(currentDir)
|
|
346
|
+
s.stop('OpenCode server started!')
|
|
342
347
|
|
|
343
348
|
s.start('Fetching OpenCode projects...')
|
|
344
349
|
|
|
345
350
|
let projects: Project[] = []
|
|
346
351
|
|
|
347
352
|
try {
|
|
348
|
-
const projectsResponse = await
|
|
353
|
+
const projectsResponse = await getClient().project.list()
|
|
349
354
|
if (!projectsResponse.data) {
|
|
350
355
|
throw new Error('Failed to fetch projects')
|
|
351
356
|
}
|
|
@@ -362,11 +367,15 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
362
367
|
}
|
|
363
368
|
|
|
364
369
|
const existingDirs = kimakiChannels.flatMap(({ channels }) =>
|
|
365
|
-
channels
|
|
370
|
+
channels
|
|
371
|
+
.filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
|
|
372
|
+
.map((ch) => ch.kimakiDirectory)
|
|
373
|
+
.filter(Boolean),
|
|
366
374
|
)
|
|
367
375
|
|
|
368
|
-
const availableProjects =
|
|
369
|
-
(project) => !existingDirs.includes(project.worktree),
|
|
376
|
+
const availableProjects = deduplicateByKey(
|
|
377
|
+
projects.filter((project) => !existingDirs.includes(project.worktree)),
|
|
378
|
+
(x) => x.worktree,
|
|
370
379
|
)
|
|
371
380
|
|
|
372
381
|
if (availableProjects.length === 0) {
|
|
@@ -376,7 +385,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
376
385
|
)
|
|
377
386
|
}
|
|
378
387
|
|
|
379
|
-
if (
|
|
388
|
+
if (
|
|
389
|
+
(!existingDirs?.length && availableProjects.length > 0) ||
|
|
390
|
+
shouldAddChannels
|
|
391
|
+
) {
|
|
380
392
|
const selectedProjects = await multiselect({
|
|
381
393
|
message: 'Select projects to create Discord channels for:',
|
|
382
394
|
options: availableProjects.map((project) => ({
|
|
@@ -422,7 +434,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
422
434
|
if (!project) continue
|
|
423
435
|
|
|
424
436
|
const baseName = path.basename(project.worktree)
|
|
425
|
-
const channelName =
|
|
437
|
+
const channelName = `${baseName}`
|
|
426
438
|
.toLowerCase()
|
|
427
439
|
.replace(/[^a-z0-9-]/g, '-')
|
|
428
440
|
.slice(0, 100)
|
package/src/discordBot.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { PassThrough, Transform, type TransformCallback } from 'node:stream'
|
|
|
40
40
|
import * as prism from 'prism-media'
|
|
41
41
|
import dedent from 'string-dedent'
|
|
42
42
|
import { transcribeAudio } from './voice.js'
|
|
43
|
-
import { extractTagsArrays } from './xml.js'
|
|
43
|
+
import { extractTagsArrays, extractNonXmlContent } from './xml.js'
|
|
44
44
|
import prettyMilliseconds from 'pretty-ms'
|
|
45
45
|
import type { Session } from '@google/genai'
|
|
46
46
|
import { createLogger } from './logger.js'
|
|
@@ -334,9 +334,9 @@ async function setupVoiceHandling({
|
|
|
334
334
|
.on('data', (frame: Buffer) => {
|
|
335
335
|
// Check if a newer speaking session has started
|
|
336
336
|
if (currentSessionCount !== speakingSessionCount) {
|
|
337
|
-
voiceLogger.log(
|
|
338
|
-
|
|
339
|
-
)
|
|
337
|
+
// voiceLogger.log(
|
|
338
|
+
// `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
|
|
339
|
+
// )
|
|
340
340
|
return
|
|
341
341
|
}
|
|
342
342
|
|
|
@@ -346,7 +346,7 @@ async function setupVoiceHandling({
|
|
|
346
346
|
)
|
|
347
347
|
return
|
|
348
348
|
}
|
|
349
|
-
voiceLogger.debug('User audio chunk length', frame.length)
|
|
349
|
+
// voiceLogger.debug('User audio chunk length', frame.length)
|
|
350
350
|
|
|
351
351
|
// Write to PCM file if stream exists
|
|
352
352
|
voiceData.userAudioStream?.write(frame)
|
|
@@ -1942,10 +1942,16 @@ export async function startDiscordBot({
|
|
|
1942
1942
|
const userParts = message.parts.filter(
|
|
1943
1943
|
(p) => p.type === 'text',
|
|
1944
1944
|
)
|
|
1945
|
-
const
|
|
1946
|
-
.map((p) =>
|
|
1945
|
+
const userTexts = userParts
|
|
1946
|
+
.map((p) => {
|
|
1947
|
+
if (typeof p.text === 'string') {
|
|
1948
|
+
return extractNonXmlContent(p.text)
|
|
1949
|
+
}
|
|
1950
|
+
return ''
|
|
1951
|
+
})
|
|
1947
1952
|
.filter((t) => t.trim())
|
|
1948
|
-
|
|
1953
|
+
|
|
1954
|
+
const userText = userTexts.join('\n\n')
|
|
1949
1955
|
if (userText) {
|
|
1950
1956
|
// Escape backticks in user messages to prevent formatting issues
|
|
1951
1957
|
const escapedText = escapeDiscordFormatting(userText)
|
package/src/markdown.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
2
2
|
import { format } from 'date-fns'
|
|
3
3
|
import * as yaml from 'js-yaml'
|
|
4
|
+
import { extractNonXmlContent } from './xml.js'
|
|
4
5
|
|
|
5
6
|
export class ShareMarkdown {
|
|
6
7
|
constructor(private client: OpencodeClient) {}
|
|
@@ -95,8 +96,11 @@ export class ShareMarkdown {
|
|
|
95
96
|
|
|
96
97
|
for (const part of parts) {
|
|
97
98
|
if (part.type === 'text' && part.text) {
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
const cleanedText = extractNonXmlContent(part.text)
|
|
100
|
+
if (cleanedText.trim()) {
|
|
101
|
+
lines.push(cleanedText)
|
|
102
|
+
lines.push('')
|
|
103
|
+
}
|
|
100
104
|
} else if (part.type === 'file') {
|
|
101
105
|
lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`)
|
|
102
106
|
if (part.url) {
|
package/src/tools.ts
CHANGED
|
@@ -26,11 +26,12 @@ export async function getTools({
|
|
|
26
26
|
sessionId: string
|
|
27
27
|
messageId: string
|
|
28
28
|
data?: { info: AssistantMessage }
|
|
29
|
-
error?:
|
|
29
|
+
error?: unknown
|
|
30
30
|
markdown?: string
|
|
31
31
|
}) => void
|
|
32
32
|
}) {
|
|
33
|
-
const
|
|
33
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
34
|
+
const client = getClient()
|
|
34
35
|
|
|
35
36
|
const markdownRenderer = new ShareMarkdown(client)
|
|
36
37
|
|
|
@@ -41,7 +42,7 @@ export async function getTools({
|
|
|
41
42
|
const getSessionModel = async (
|
|
42
43
|
sessionId: string,
|
|
43
44
|
): Promise<{ providerID: string; modelID: string } | undefined> => {
|
|
44
|
-
const res = await
|
|
45
|
+
const res = await getClient().session.messages({ path: { id: sessionId } })
|
|
45
46
|
const data = res.data
|
|
46
47
|
if (!data || data.length === 0) return undefined
|
|
47
48
|
for (let i = data.length - 1; i >= 0; i--) {
|
|
@@ -68,8 +69,8 @@ export async function getTools({
|
|
|
68
69
|
const sessionModel = await getSessionModel(sessionId)
|
|
69
70
|
|
|
70
71
|
// do not await
|
|
71
|
-
|
|
72
|
-
.prompt({
|
|
72
|
+
getClient()
|
|
73
|
+
.session.prompt({
|
|
73
74
|
path: { id: sessionId },
|
|
74
75
|
|
|
75
76
|
body: {
|
|
@@ -132,7 +133,7 @@ export async function getTools({
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
try {
|
|
135
|
-
const session = await
|
|
136
|
+
const session = await getClient().session.create({
|
|
136
137
|
body: {
|
|
137
138
|
title: title || message.slice(0, 50),
|
|
138
139
|
},
|
|
@@ -143,8 +144,8 @@ export async function getTools({
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// do not await
|
|
146
|
-
|
|
147
|
-
.prompt({
|
|
147
|
+
getClient()
|
|
148
|
+
.session.prompt({
|
|
148
149
|
path: { id: session.data.id },
|
|
149
150
|
body: {
|
|
150
151
|
parts: [{ type: 'text', text: message }],
|
|
@@ -193,7 +194,7 @@ export async function getTools({
|
|
|
193
194
|
inputSchema: z.object({}),
|
|
194
195
|
execute: async () => {
|
|
195
196
|
toolsLogger.log(`Listing opencode sessions`)
|
|
196
|
-
const sessions = await
|
|
197
|
+
const sessions = await getClient().session.list()
|
|
197
198
|
|
|
198
199
|
if (!sessions.data) {
|
|
199
200
|
return { success: false, error: 'No sessions found' }
|
|
@@ -209,7 +210,7 @@ export async function getTools({
|
|
|
209
210
|
const finishedAt = session.time.updated
|
|
210
211
|
const status = await (async () => {
|
|
211
212
|
if (session.revert) return 'error'
|
|
212
|
-
const messagesResponse = await
|
|
213
|
+
const messagesResponse = await getClient().session.messages({
|
|
213
214
|
path: { id: session.id },
|
|
214
215
|
})
|
|
215
216
|
const messages = messagesResponse.data || []
|
|
@@ -256,7 +257,7 @@ export async function getTools({
|
|
|
256
257
|
query: z.string().describe('The search query for files'),
|
|
257
258
|
}),
|
|
258
259
|
execute: async ({ folder, query }) => {
|
|
259
|
-
const results = await
|
|
260
|
+
const results = await getClient().find.files({
|
|
260
261
|
query: {
|
|
261
262
|
query,
|
|
262
263
|
directory: folder,
|
|
@@ -281,7 +282,7 @@ export async function getTools({
|
|
|
281
282
|
}),
|
|
282
283
|
execute: async ({ sessionId, lastAssistantOnly = false }) => {
|
|
283
284
|
if (lastAssistantOnly) {
|
|
284
|
-
const messages = await
|
|
285
|
+
const messages = await getClient().session.messages({
|
|
285
286
|
path: { id: sessionId },
|
|
286
287
|
})
|
|
287
288
|
|
|
@@ -322,7 +323,7 @@ export async function getTools({
|
|
|
322
323
|
sessionID: sessionId,
|
|
323
324
|
})
|
|
324
325
|
|
|
325
|
-
const messages = await
|
|
326
|
+
const messages = await getClient().session.messages({
|
|
326
327
|
path: { id: sessionId },
|
|
327
328
|
})
|
|
328
329
|
const lastMessage = messages.data?.[messages.data.length - 1]
|
|
@@ -350,7 +351,7 @@ export async function getTools({
|
|
|
350
351
|
}),
|
|
351
352
|
execute: async ({ sessionId }) => {
|
|
352
353
|
try {
|
|
353
|
-
const result = await
|
|
354
|
+
const result = await getClient().session.abort({
|
|
354
355
|
path: { id: sessionId },
|
|
355
356
|
})
|
|
356
357
|
|
|
@@ -381,7 +382,7 @@ export async function getTools({
|
|
|
381
382
|
inputSchema: z.object({}),
|
|
382
383
|
execute: async () => {
|
|
383
384
|
try {
|
|
384
|
-
const providersResponse = await
|
|
385
|
+
const providersResponse = await getClient().config.providers({})
|
|
385
386
|
const providers: Provider[] = providersResponse.data?.providers || []
|
|
386
387
|
|
|
387
388
|
const models: Array<{ providerId: string; modelId: string }> = []
|
package/src/utils.ts
CHANGED
|
@@ -48,26 +48,15 @@ export function generateBotInstallUrl({
|
|
|
48
48
|
return url.toString()
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function getRequiredBotPermissions(): bigint[] {
|
|
52
|
-
return [
|
|
53
|
-
PermissionsBitField.Flags.ViewChannel,
|
|
54
|
-
PermissionsBitField.Flags.ManageChannels,
|
|
55
|
-
PermissionsBitField.Flags.SendMessages,
|
|
56
|
-
PermissionsBitField.Flags.SendMessagesInThreads,
|
|
57
|
-
PermissionsBitField.Flags.CreatePublicThreads,
|
|
58
|
-
PermissionsBitField.Flags.ManageThreads,
|
|
59
|
-
PermissionsBitField.Flags.ReadMessageHistory,
|
|
60
|
-
PermissionsBitField.Flags.AddReactions,
|
|
61
|
-
PermissionsBitField.Flags.ManageMessages,
|
|
62
|
-
PermissionsBitField.Flags.UseExternalEmojis,
|
|
63
|
-
PermissionsBitField.Flags.AttachFiles,
|
|
64
|
-
PermissionsBitField.Flags.Connect,
|
|
65
|
-
PermissionsBitField.Flags.Speak,
|
|
66
|
-
]
|
|
67
|
-
}
|
|
68
51
|
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
52
|
+
export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
|
|
53
|
+
const seen = new Set<K>()
|
|
54
|
+
return arr.filter(item => {
|
|
55
|
+
const key = keyFn(item)
|
|
56
|
+
if (seen.has(key)) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
seen.add(key)
|
|
60
|
+
return true
|
|
61
|
+
})
|
|
73
62
|
}
|
package/src/xml.test.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { extractNonXmlContent } from './xml.js'
|
|
3
|
+
|
|
4
|
+
describe('extractNonXmlContent', () => {
|
|
5
|
+
test('removes xml tags and returns only text content', () => {
|
|
6
|
+
const xml = 'Hello <tag>content</tag> world <nested><inner>deep</inner></nested> end'
|
|
7
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
|
|
8
|
+
"Hello
|
|
9
|
+
world
|
|
10
|
+
end"
|
|
11
|
+
`)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('handles multiple text segments', () => {
|
|
15
|
+
const xml = 'Start <a>tag1</a> middle <b>tag2</b> finish'
|
|
16
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
|
|
17
|
+
"Start
|
|
18
|
+
middle
|
|
19
|
+
finish"
|
|
20
|
+
`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('handles only xml without text', () => {
|
|
24
|
+
const xml = '<root><child>content</child></root>'
|
|
25
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('handles only text without xml', () => {
|
|
29
|
+
const xml = 'Just plain text'
|
|
30
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`"Just plain text"`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('handles empty string', () => {
|
|
34
|
+
const xml = ''
|
|
35
|
+
expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`)
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/xml.ts
CHANGED
|
@@ -110,3 +110,8 @@ export function extractTagsArrays<T extends string>({
|
|
|
110
110
|
|
|
111
111
|
return result as Record<T, string[]> & { others: string[] }
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
export function extractNonXmlContent(xml: string): string {
|
|
115
|
+
const result = extractTagsArrays({ xml, tags: [] })
|
|
116
|
+
return result.others.join('\n')
|
|
117
|
+
}
|
package/bin.sh
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Restarts dist/cli.js if it exits non-zero.
|
|
3
|
-
# Throttles restarts to at most once every 5 seconds.
|
|
4
|
-
|
|
5
|
-
set -u -o pipefail
|
|
6
|
-
|
|
7
|
-
NODE_BIN="${NODE_BIN:-node}"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
last_start=0
|
|
11
|
-
|
|
12
|
-
while :; do
|
|
13
|
-
now=$(date +%s)
|
|
14
|
-
elapsed=$(( now - last_start ))
|
|
15
|
-
if (( elapsed < 5 )); then
|
|
16
|
-
sleep $(( 5 - elapsed ))
|
|
17
|
-
fi
|
|
18
|
-
last_start=$(date +%s)
|
|
19
|
-
|
|
20
|
-
"$NODE_BIN" "./dist/cli.js" "$@"
|
|
21
|
-
code=$?
|
|
22
|
-
|
|
23
|
-
# Exit cleanly if the app ended OK or via SIGINT/SIGTERM
|
|
24
|
-
if (( code == 0 || code == 130 || code == 143 || code == 64 )); then
|
|
25
|
-
exit "$code"
|
|
26
|
-
fi
|
|
27
|
-
# otherwise loop; the 5s throttle above will apply
|
|
28
|
-
done
|