kimaki 0.1.0 → 0.1.2
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 +4 -13
- package/dist/discordBot.js +57 -37
- package/dist/markdown.js +6 -2
- package/dist/tools.js +15 -14
- package/dist/xml.js +4 -0
- package/dist/xml.test.js +32 -0
- package/package.json +2 -2
- package/src/cli.ts +4 -16
- package/src/discordBot.ts +10 -4
- package/src/markdown.ts +6 -2
- package/src/tools.ts +16 -15
- 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
|
@@ -197,22 +197,13 @@ async function run({ restart, addChannels }) {
|
|
|
197
197
|
note(channelList, 'Existing Kimaki Channels');
|
|
198
198
|
}
|
|
199
199
|
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
|
-
}
|
|
200
|
+
const currentDir = process.cwd();
|
|
201
|
+
let getClient = await initializeOpencodeForDirectory(currentDir);
|
|
202
|
+
s.stop('OpenCode server started!');
|
|
212
203
|
s.start('Fetching OpenCode projects...');
|
|
213
204
|
let projects = [];
|
|
214
205
|
try {
|
|
215
|
-
const projectsResponse = await
|
|
206
|
+
const projectsResponse = await getClient().project.list();
|
|
216
207
|
if (!projectsResponse.data) {
|
|
217
208
|
throw new Error('Failed to fetch projects');
|
|
218
209
|
}
|
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
|
|
@@ -551,29 +553,19 @@ function getKimakiMetadata(textChannel) {
|
|
|
551
553
|
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
552
554
|
return { projectDirectory, channelAppId };
|
|
553
555
|
}
|
|
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
556
|
export async function initializeOpencodeForDirectory(directory) {
|
|
571
557
|
// console.log(`[OPENCODE] Initializing for directory: ${directory}`)
|
|
572
558
|
// Check if we already have a server for this directory
|
|
573
559
|
const existing = opencodeServers.get(directory);
|
|
574
560
|
if (existing && !existing.process.killed) {
|
|
575
561
|
opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
|
|
576
|
-
return
|
|
562
|
+
return () => {
|
|
563
|
+
const entry = opencodeServers.get(directory);
|
|
564
|
+
if (!entry?.client) {
|
|
565
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
566
|
+
}
|
|
567
|
+
return entry.client;
|
|
568
|
+
};
|
|
577
569
|
}
|
|
578
570
|
const port = await getOpenPort();
|
|
579
571
|
// console.log(
|
|
@@ -589,17 +581,34 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
589
581
|
},
|
|
590
582
|
});
|
|
591
583
|
serverProcess.stdout?.on('data', (data) => {
|
|
592
|
-
opencodeLogger.log(`
|
|
584
|
+
opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
|
|
593
585
|
});
|
|
594
586
|
serverProcess.stderr?.on('data', (data) => {
|
|
595
|
-
opencodeLogger.error(`
|
|
587
|
+
opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
|
|
596
588
|
});
|
|
597
589
|
serverProcess.on('error', (error) => {
|
|
598
|
-
opencodeLogger.error(`Failed to start server on port :`, error);
|
|
590
|
+
opencodeLogger.error(`Failed to start server on port :`, port, error);
|
|
599
591
|
});
|
|
600
592
|
serverProcess.on('exit', (code) => {
|
|
601
|
-
opencodeLogger.log(`
|
|
593
|
+
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
602
594
|
opencodeServers.delete(directory);
|
|
595
|
+
if (code !== 0) {
|
|
596
|
+
const retryCount = serverRetryCount.get(directory) || 0;
|
|
597
|
+
if (retryCount < 5) {
|
|
598
|
+
serverRetryCount.set(directory, retryCount + 1);
|
|
599
|
+
opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
|
|
600
|
+
initializeOpencodeForDirectory(directory).catch((e) => {
|
|
601
|
+
opencodeLogger.error(`Failed to restart opencode server:`, e);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// Reset retry count on clean exit
|
|
610
|
+
serverRetryCount.delete(directory);
|
|
611
|
+
}
|
|
603
612
|
});
|
|
604
613
|
await waitForServer(port);
|
|
605
614
|
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
|
|
@@ -608,7 +617,13 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
608
617
|
client,
|
|
609
618
|
port,
|
|
610
619
|
});
|
|
611
|
-
return
|
|
620
|
+
return () => {
|
|
621
|
+
const entry = opencodeServers.get(directory);
|
|
622
|
+
if (!entry?.client) {
|
|
623
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
624
|
+
}
|
|
625
|
+
return entry.client;
|
|
626
|
+
};
|
|
612
627
|
}
|
|
613
628
|
function formatPart(part) {
|
|
614
629
|
switch (part.type) {
|
|
@@ -746,7 +761,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
746
761
|
const directory = projectDirectory || process.cwd();
|
|
747
762
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
748
763
|
// Note: We'll cancel the existing request after we have the session ID
|
|
749
|
-
const
|
|
764
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
750
765
|
// Get session ID from database
|
|
751
766
|
const row = getDatabase()
|
|
752
767
|
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
@@ -756,7 +771,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
756
771
|
if (sessionId) {
|
|
757
772
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
|
|
758
773
|
try {
|
|
759
|
-
const sessionResponse = await
|
|
774
|
+
const sessionResponse = await getClient().session.get({
|
|
760
775
|
path: { id: sessionId },
|
|
761
776
|
});
|
|
762
777
|
session = sessionResponse.data;
|
|
@@ -768,7 +783,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
768
783
|
}
|
|
769
784
|
if (!session) {
|
|
770
785
|
voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
|
|
771
|
-
const sessionResponse = await
|
|
786
|
+
const sessionResponse = await getClient().session.create({
|
|
772
787
|
body: { title: prompt.slice(0, 80) },
|
|
773
788
|
});
|
|
774
789
|
session = sessionResponse.data;
|
|
@@ -794,7 +809,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
794
809
|
const abortController = new AbortController();
|
|
795
810
|
// Store this controller for this session
|
|
796
811
|
abortControllers.set(session.id, abortController);
|
|
797
|
-
const eventsResult = await
|
|
812
|
+
const eventsResult = await getClient().event.subscribe({
|
|
798
813
|
signal: abortController.signal,
|
|
799
814
|
});
|
|
800
815
|
const events = eventsResult.stream;
|
|
@@ -1034,7 +1049,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1034
1049
|
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1035
1050
|
// Start the event handler
|
|
1036
1051
|
const eventHandlerPromise = eventHandler();
|
|
1037
|
-
const response = await
|
|
1052
|
+
const response = await getClient().session.prompt({
|
|
1038
1053
|
path: { id: session.id },
|
|
1039
1054
|
body: {
|
|
1040
1055
|
parts: [{ type: 'text', text: prompt }],
|
|
@@ -1322,9 +1337,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1322
1337
|
}
|
|
1323
1338
|
try {
|
|
1324
1339
|
// Get OpenCode client for this directory
|
|
1325
|
-
const
|
|
1340
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
1326
1341
|
// List sessions
|
|
1327
|
-
const sessionsResponse = await
|
|
1342
|
+
const sessionsResponse = await getClient().session.list();
|
|
1328
1343
|
if (!sessionsResponse.data) {
|
|
1329
1344
|
await interaction.respond([]);
|
|
1330
1345
|
return;
|
|
@@ -1385,9 +1400,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1385
1400
|
}
|
|
1386
1401
|
try {
|
|
1387
1402
|
// Initialize OpenCode client for the directory
|
|
1388
|
-
const
|
|
1403
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
1389
1404
|
// Get session title
|
|
1390
|
-
const sessionResponse = await
|
|
1405
|
+
const sessionResponse = await getClient().session.get({
|
|
1391
1406
|
path: { id: sessionId },
|
|
1392
1407
|
});
|
|
1393
1408
|
if (!sessionResponse.data) {
|
|
@@ -1407,7 +1422,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1407
1422
|
.run(thread.id, sessionId);
|
|
1408
1423
|
voiceLogger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`);
|
|
1409
1424
|
// Fetch all messages for the session
|
|
1410
|
-
const messagesResponse = await
|
|
1425
|
+
const messagesResponse = await getClient().session.messages({
|
|
1411
1426
|
path: { id: sessionId },
|
|
1412
1427
|
});
|
|
1413
1428
|
if (!messagesResponse.data) {
|
|
@@ -1423,10 +1438,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1423
1438
|
if (message.info.role === 'user') {
|
|
1424
1439
|
// Render user messages
|
|
1425
1440
|
const userParts = message.parts.filter((p) => p.type === 'text');
|
|
1426
|
-
const
|
|
1427
|
-
.map((p) =>
|
|
1428
|
-
|
|
1429
|
-
|
|
1441
|
+
const userTexts = userParts
|
|
1442
|
+
.map((p) => {
|
|
1443
|
+
if (typeof p.text === 'string') {
|
|
1444
|
+
return extractNonXmlContent(p.text);
|
|
1445
|
+
}
|
|
1446
|
+
return '';
|
|
1447
|
+
})
|
|
1448
|
+
.filter((t) => t.trim());
|
|
1449
|
+
const userText = userTexts.join('\n\n');
|
|
1430
1450
|
if (userText) {
|
|
1431
1451
|
// Escape backticks in user messages to prevent formatting issues
|
|
1432
1452
|
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/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
|
@@ -324,28 +324,16 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
324
324
|
|
|
325
325
|
s.start('Starting OpenCode server...')
|
|
326
326
|
|
|
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
|
-
}
|
|
327
|
+
const currentDir = process.cwd()
|
|
328
|
+
let getClient = await initializeOpencodeForDirectory(currentDir)
|
|
329
|
+
s.stop('OpenCode server started!')
|
|
342
330
|
|
|
343
331
|
s.start('Fetching OpenCode projects...')
|
|
344
332
|
|
|
345
333
|
let projects: Project[] = []
|
|
346
334
|
|
|
347
335
|
try {
|
|
348
|
-
const projectsResponse = await
|
|
336
|
+
const projectsResponse = await getClient().project.list()
|
|
349
337
|
if (!projectsResponse.data) {
|
|
350
338
|
throw new Error('Failed to fetch projects')
|
|
351
339
|
}
|
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'
|
|
@@ -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/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
|