kimaki 0.4.7 → 0.4.10
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/README.md +67 -5
- package/dist/cli.js +146 -0
- package/dist/opencode-plugin.js +56 -0
- package/dist/voice.js +11 -1
- package/package.json +2 -1
- package/src/cli.ts +212 -0
- package/src/opencode-command.md +4 -0
- package/src/opencode-plugin.ts +75 -0
- package/src/voice.ts +11 -1
package/README.md
CHANGED
|
@@ -1,7 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
# Kimaki Discord Bot
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
- get bot app id
|
|
5
|
-
- get bot token in Discord clicking reset bot token. show a password input
|
|
3
|
+
A Discord bot that integrates OpenCode coding sessions with Discord channels and voice.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g kimaki
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Run the interactive setup:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
kimaki
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This will guide you through:
|
|
20
|
+
1. Creating a Discord application at https://discord.com/developers/applications
|
|
21
|
+
2. Getting your bot token
|
|
22
|
+
3. Installing the bot to your Discord server
|
|
23
|
+
4. Creating channels for your OpenCode projects
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
### Start the bot
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
kimaki
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Send a session to Discord
|
|
34
|
+
|
|
35
|
+
Send an OpenCode session to Discord from the CLI:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
kimaki send-to-discord <session-id>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
- `-d, --directory <dir>` - Project directory (defaults to current working directory)
|
|
43
|
+
|
|
44
|
+
### OpenCode Integration
|
|
45
|
+
|
|
46
|
+
To use the `/send-to-kimaki-discord` command in OpenCode:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx kimaki install-plugin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then use `/send-to-kimaki-discord` in OpenCode to send the current session to Discord.
|
|
53
|
+
|
|
54
|
+
## Discord Slash Commands
|
|
55
|
+
|
|
56
|
+
Once the bot is running, you can use these commands in Discord:
|
|
57
|
+
|
|
58
|
+
- `/session <prompt>` - Start a new OpenCode session
|
|
59
|
+
- `/resume <session>` - Resume an existing session
|
|
60
|
+
- `/add-project <project>` - Add a new project to Discord
|
|
61
|
+
- `/accept` - Accept a permission request
|
|
62
|
+
- `/accept-always` - Accept and auto-approve similar requests
|
|
63
|
+
- `/reject` - Reject a permission request
|
|
64
|
+
|
|
65
|
+
## Voice Support
|
|
66
|
+
|
|
67
|
+
Join a voice channel that has an associated project directory, and the bot will join with Jarvis-like voice interaction powered by Gemini.
|
|
68
|
+
|
|
69
|
+
Requires a Gemini API key (prompted during setup).
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDis
|
|
|
6
6
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import os from 'node:os';
|
|
9
11
|
import { createLogger } from './logger.js';
|
|
10
12
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
11
13
|
const cliLogger = createLogger('CLI');
|
|
@@ -435,5 +437,149 @@ cli
|
|
|
435
437
|
process.exit(EXIT_NO_RESTART);
|
|
436
438
|
}
|
|
437
439
|
});
|
|
440
|
+
cli
|
|
441
|
+
.command('send-to-discord <sessionId>', 'Send an OpenCode session to Discord and create a thread for it')
|
|
442
|
+
.option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
|
|
443
|
+
.action(async (sessionId, options) => {
|
|
444
|
+
try {
|
|
445
|
+
const directory = options.directory || process.cwd();
|
|
446
|
+
const db = getDatabase();
|
|
447
|
+
const botRow = db
|
|
448
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
449
|
+
.get();
|
|
450
|
+
if (!botRow) {
|
|
451
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.');
|
|
452
|
+
process.exit(EXIT_NO_RESTART);
|
|
453
|
+
}
|
|
454
|
+
const channelRow = db
|
|
455
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
456
|
+
.get(directory, 'text');
|
|
457
|
+
if (!channelRow) {
|
|
458
|
+
cliLogger.error(`No Discord channel found for directory: ${directory}\n` +
|
|
459
|
+
'Run `kimaki --add-channels` to create a channel for this project.');
|
|
460
|
+
process.exit(EXIT_NO_RESTART);
|
|
461
|
+
}
|
|
462
|
+
const s = spinner();
|
|
463
|
+
s.start('Connecting to Discord...');
|
|
464
|
+
const discordClient = await createDiscordClient();
|
|
465
|
+
await new Promise((resolve, reject) => {
|
|
466
|
+
discordClient.once(Events.ClientReady, () => {
|
|
467
|
+
resolve();
|
|
468
|
+
});
|
|
469
|
+
discordClient.once(Events.Error, reject);
|
|
470
|
+
discordClient.login(botRow.token).catch(reject);
|
|
471
|
+
});
|
|
472
|
+
s.stop('Connected to Discord!');
|
|
473
|
+
const channel = await discordClient.channels.fetch(channelRow.channel_id);
|
|
474
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
475
|
+
cliLogger.error('Could not find the text channel or it is not a text channel');
|
|
476
|
+
discordClient.destroy();
|
|
477
|
+
process.exit(EXIT_NO_RESTART);
|
|
478
|
+
}
|
|
479
|
+
const textChannel = channel;
|
|
480
|
+
s.start('Fetching session from OpenCode...');
|
|
481
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
482
|
+
const sessionResponse = await getClient().session.get({
|
|
483
|
+
path: { id: sessionId },
|
|
484
|
+
});
|
|
485
|
+
if (!sessionResponse.data) {
|
|
486
|
+
s.stop('Session not found');
|
|
487
|
+
discordClient.destroy();
|
|
488
|
+
process.exit(EXIT_NO_RESTART);
|
|
489
|
+
}
|
|
490
|
+
const session = sessionResponse.data;
|
|
491
|
+
s.stop(`Found session: ${session.title}`);
|
|
492
|
+
s.start('Creating Discord thread...');
|
|
493
|
+
const thread = await textChannel.threads.create({
|
|
494
|
+
name: `Resume: ${session.title}`.slice(0, 100),
|
|
495
|
+
autoArchiveDuration: 1440,
|
|
496
|
+
reason: `Resuming session ${sessionId} from CLI`,
|
|
497
|
+
});
|
|
498
|
+
db.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)').run(thread.id, sessionId);
|
|
499
|
+
s.stop('Created Discord thread!');
|
|
500
|
+
s.start('Loading session messages...');
|
|
501
|
+
const messagesResponse = await getClient().session.messages({
|
|
502
|
+
path: { id: sessionId },
|
|
503
|
+
});
|
|
504
|
+
if (!messagesResponse.data) {
|
|
505
|
+
s.stop('Failed to fetch session messages');
|
|
506
|
+
discordClient.destroy();
|
|
507
|
+
process.exit(EXIT_NO_RESTART);
|
|
508
|
+
}
|
|
509
|
+
const messages = messagesResponse.data;
|
|
510
|
+
await thread.send(`📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
|
|
511
|
+
let messageCount = 0;
|
|
512
|
+
for (const message of messages) {
|
|
513
|
+
if (message.info.role === 'user') {
|
|
514
|
+
const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
|
|
515
|
+
const userTexts = userParts
|
|
516
|
+
.map((p) => {
|
|
517
|
+
if (p.type === 'text') {
|
|
518
|
+
return p.text;
|
|
519
|
+
}
|
|
520
|
+
return '';
|
|
521
|
+
})
|
|
522
|
+
.filter((t) => t.trim());
|
|
523
|
+
const userText = userTexts.join('\n\n');
|
|
524
|
+
if (userText) {
|
|
525
|
+
const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText;
|
|
526
|
+
await thread.send(`**User:**\n${truncated}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (message.info.role === 'assistant') {
|
|
530
|
+
const textParts = message.parts.filter((p) => p.type === 'text');
|
|
531
|
+
const texts = textParts
|
|
532
|
+
.map((p) => {
|
|
533
|
+
if (p.type === 'text') {
|
|
534
|
+
return p.text;
|
|
535
|
+
}
|
|
536
|
+
return '';
|
|
537
|
+
})
|
|
538
|
+
.filter((t) => t?.trim());
|
|
539
|
+
if (texts.length > 0) {
|
|
540
|
+
const combinedText = texts.join('\n\n');
|
|
541
|
+
const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText;
|
|
542
|
+
await thread.send(truncated);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
messageCount++;
|
|
546
|
+
}
|
|
547
|
+
await thread.send(`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
|
|
548
|
+
s.stop(`Loaded ${messageCount} messages`);
|
|
549
|
+
const guildId = textChannel.guildId;
|
|
550
|
+
const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`;
|
|
551
|
+
note(`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`, '✅ Success');
|
|
552
|
+
discordClient.destroy();
|
|
553
|
+
process.exit(0);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
557
|
+
process.exit(EXIT_NO_RESTART);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
cli
|
|
561
|
+
.command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
|
|
562
|
+
.action(async () => {
|
|
563
|
+
try {
|
|
564
|
+
const require = createRequire(import.meta.url);
|
|
565
|
+
const pluginSrc = require.resolve('./opencode-plugin.ts');
|
|
566
|
+
const commandSrc = require.resolve('./opencode-command.md');
|
|
567
|
+
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode');
|
|
568
|
+
const pluginDir = path.join(opencodeConfig, 'plugin');
|
|
569
|
+
const commandDir = path.join(opencodeConfig, 'command');
|
|
570
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
571
|
+
fs.mkdirSync(commandDir, { recursive: true });
|
|
572
|
+
const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts');
|
|
573
|
+
const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md');
|
|
574
|
+
fs.copyFileSync(pluginSrc, pluginDest);
|
|
575
|
+
fs.copyFileSync(commandSrc, commandDest);
|
|
576
|
+
note(`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`, '✅ Installed');
|
|
577
|
+
process.exit(0);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
581
|
+
process.exit(EXIT_NO_RESTART);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
438
584
|
cli.help();
|
|
439
585
|
cli.parse();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimaki Discord Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Adds /send-to-kimaki-discord command that sends the current session to Discord.
|
|
5
|
+
*
|
|
6
|
+
* Installation:
|
|
7
|
+
* kimaki install-plugin
|
|
8
|
+
*
|
|
9
|
+
* Use in OpenCode TUI:
|
|
10
|
+
* /send-to-kimaki-discord
|
|
11
|
+
*/
|
|
12
|
+
export const KimakiDiscordPlugin = async ({ client, $, directory, }) => {
|
|
13
|
+
return {
|
|
14
|
+
event: async ({ event }) => {
|
|
15
|
+
if (event.type !== 'command.executed') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { name, sessionID } = event.properties;
|
|
19
|
+
if (name !== 'send-to-kimaki-discord') {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!sessionID) {
|
|
23
|
+
await client.tui.showToast({
|
|
24
|
+
body: { message: 'No session ID available', variant: 'error' },
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await client.tui.showToast({
|
|
29
|
+
body: { message: 'Creating Discord thread...', variant: 'info' },
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const result = await $ `npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text();
|
|
33
|
+
const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/);
|
|
34
|
+
const url = urlMatch ? urlMatch[0] : null;
|
|
35
|
+
await client.tui.showToast({
|
|
36
|
+
body: {
|
|
37
|
+
message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
|
|
38
|
+
variant: 'success',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const message = error.stderr?.toString().trim() ||
|
|
44
|
+
error.stdout?.toString().trim() ||
|
|
45
|
+
error.message ||
|
|
46
|
+
String(error);
|
|
47
|
+
await client.tui.showToast({
|
|
48
|
+
body: {
|
|
49
|
+
message: `Failed: ${message.slice(0, 100)}`,
|
|
50
|
+
variant: 'error',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
package/dist/voice.js
CHANGED
|
@@ -28,7 +28,17 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
|
|
|
28
28
|
throw new Error('Invalid audio format');
|
|
29
29
|
}
|
|
30
30
|
// Build the transcription prompt
|
|
31
|
-
let transcriptionPrompt = `
|
|
31
|
+
let transcriptionPrompt = `Transcribe this audio accurately. The transcription will be sent to a coding agent (like Claude Code) to execute programming tasks.
|
|
32
|
+
|
|
33
|
+
Assume the speaker is using technical and programming terminology: file paths, function names, CLI commands, package names, API names, programming concepts, etc. Prioritize technical accuracy over literal transcription - if a word sounds like a common programming term, prefer that interpretation.
|
|
34
|
+
|
|
35
|
+
If the spoken message is unclear or ambiguous, rephrase it to better convey the intended meaning for a coding agent. The goal is effective communication of the user's programming intent, not a word-for-word transcription.
|
|
36
|
+
|
|
37
|
+
Here are relevant filenames and context that may appear in the audio:
|
|
38
|
+
<context>
|
|
39
|
+
${prompt}
|
|
40
|
+
</context>
|
|
41
|
+
`;
|
|
32
42
|
if (language) {
|
|
33
43
|
transcriptionPrompt += `\nThe audio is in ${language}.`;
|
|
34
44
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.10",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"bin.js"
|
|
22
22
|
],
|
|
23
23
|
"devDependencies": {
|
|
24
|
+
"@opencode-ai/plugin": "^1.0.119",
|
|
24
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
25
26
|
"@types/bun": "latest",
|
|
26
27
|
"@types/js-yaml": "^4.0.9",
|
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
} from 'discord.js'
|
|
37
37
|
import path from 'node:path'
|
|
38
38
|
import fs from 'node:fs'
|
|
39
|
+
import { createRequire } from 'node:module'
|
|
40
|
+
import os from 'node:os'
|
|
39
41
|
import { createLogger } from './logger.js'
|
|
40
42
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
41
43
|
|
|
@@ -655,5 +657,215 @@ cli
|
|
|
655
657
|
}
|
|
656
658
|
})
|
|
657
659
|
|
|
660
|
+
cli
|
|
661
|
+
.command(
|
|
662
|
+
'send-to-discord <sessionId>',
|
|
663
|
+
'Send an OpenCode session to Discord and create a thread for it',
|
|
664
|
+
)
|
|
665
|
+
.option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
|
|
666
|
+
.action(async (sessionId: string, options: { directory?: string }) => {
|
|
667
|
+
try {
|
|
668
|
+
const directory = options.directory || process.cwd()
|
|
669
|
+
|
|
670
|
+
const db = getDatabase()
|
|
671
|
+
|
|
672
|
+
const botRow = db
|
|
673
|
+
.prepare(
|
|
674
|
+
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
675
|
+
)
|
|
676
|
+
.get() as { app_id: string; token: string } | undefined
|
|
677
|
+
|
|
678
|
+
if (!botRow) {
|
|
679
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
|
|
680
|
+
process.exit(EXIT_NO_RESTART)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const channelRow = db
|
|
684
|
+
.prepare(
|
|
685
|
+
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
686
|
+
)
|
|
687
|
+
.get(directory, 'text') as { channel_id: string } | undefined
|
|
688
|
+
|
|
689
|
+
if (!channelRow) {
|
|
690
|
+
cliLogger.error(
|
|
691
|
+
`No Discord channel found for directory: ${directory}\n` +
|
|
692
|
+
'Run `kimaki --add-channels` to create a channel for this project.',
|
|
693
|
+
)
|
|
694
|
+
process.exit(EXIT_NO_RESTART)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const s = spinner()
|
|
698
|
+
s.start('Connecting to Discord...')
|
|
699
|
+
|
|
700
|
+
const discordClient = await createDiscordClient()
|
|
701
|
+
|
|
702
|
+
await new Promise<void>((resolve, reject) => {
|
|
703
|
+
discordClient.once(Events.ClientReady, () => {
|
|
704
|
+
resolve()
|
|
705
|
+
})
|
|
706
|
+
discordClient.once(Events.Error, reject)
|
|
707
|
+
discordClient.login(botRow.token).catch(reject)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
s.stop('Connected to Discord!')
|
|
711
|
+
|
|
712
|
+
const channel = await discordClient.channels.fetch(channelRow.channel_id)
|
|
713
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
714
|
+
cliLogger.error('Could not find the text channel or it is not a text channel')
|
|
715
|
+
discordClient.destroy()
|
|
716
|
+
process.exit(EXIT_NO_RESTART)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const textChannel = channel as import('discord.js').TextChannel
|
|
720
|
+
|
|
721
|
+
s.start('Fetching session from OpenCode...')
|
|
722
|
+
|
|
723
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
724
|
+
const sessionResponse = await getClient().session.get({
|
|
725
|
+
path: { id: sessionId },
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
if (!sessionResponse.data) {
|
|
729
|
+
s.stop('Session not found')
|
|
730
|
+
discordClient.destroy()
|
|
731
|
+
process.exit(EXIT_NO_RESTART)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const session = sessionResponse.data
|
|
735
|
+
s.stop(`Found session: ${session.title}`)
|
|
736
|
+
|
|
737
|
+
s.start('Creating Discord thread...')
|
|
738
|
+
|
|
739
|
+
const thread = await textChannel.threads.create({
|
|
740
|
+
name: `Resume: ${session.title}`.slice(0, 100),
|
|
741
|
+
autoArchiveDuration: 1440,
|
|
742
|
+
reason: `Resuming session ${sessionId} from CLI`,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
db.prepare(
|
|
746
|
+
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
747
|
+
).run(thread.id, sessionId)
|
|
748
|
+
|
|
749
|
+
s.stop('Created Discord thread!')
|
|
750
|
+
|
|
751
|
+
s.start('Loading session messages...')
|
|
752
|
+
|
|
753
|
+
const messagesResponse = await getClient().session.messages({
|
|
754
|
+
path: { id: sessionId },
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
if (!messagesResponse.data) {
|
|
758
|
+
s.stop('Failed to fetch session messages')
|
|
759
|
+
discordClient.destroy()
|
|
760
|
+
process.exit(EXIT_NO_RESTART)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const messages = messagesResponse.data
|
|
764
|
+
|
|
765
|
+
await thread.send(
|
|
766
|
+
`📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
let messageCount = 0
|
|
770
|
+
for (const message of messages) {
|
|
771
|
+
if (message.info.role === 'user') {
|
|
772
|
+
const userParts = message.parts.filter(
|
|
773
|
+
(p) => p.type === 'text' && !p.synthetic,
|
|
774
|
+
)
|
|
775
|
+
const userTexts = userParts
|
|
776
|
+
.map((p) => {
|
|
777
|
+
if (p.type === 'text') {
|
|
778
|
+
return p.text
|
|
779
|
+
}
|
|
780
|
+
return ''
|
|
781
|
+
})
|
|
782
|
+
.filter((t) => t.trim())
|
|
783
|
+
|
|
784
|
+
const userText = userTexts.join('\n\n')
|
|
785
|
+
if (userText) {
|
|
786
|
+
const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText
|
|
787
|
+
await thread.send(`**User:**\n${truncated}`)
|
|
788
|
+
}
|
|
789
|
+
} else if (message.info.role === 'assistant') {
|
|
790
|
+
const textParts = message.parts.filter((p) => p.type === 'text')
|
|
791
|
+
const texts = textParts
|
|
792
|
+
.map((p) => {
|
|
793
|
+
if (p.type === 'text') {
|
|
794
|
+
return p.text
|
|
795
|
+
}
|
|
796
|
+
return ''
|
|
797
|
+
})
|
|
798
|
+
.filter((t) => t?.trim())
|
|
799
|
+
|
|
800
|
+
if (texts.length > 0) {
|
|
801
|
+
const combinedText = texts.join('\n\n')
|
|
802
|
+
const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText
|
|
803
|
+
await thread.send(truncated)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
messageCount++
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
await thread.send(
|
|
810
|
+
`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
s.stop(`Loaded ${messageCount} messages`)
|
|
814
|
+
|
|
815
|
+
const guildId = textChannel.guildId
|
|
816
|
+
const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`
|
|
817
|
+
|
|
818
|
+
note(
|
|
819
|
+
`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`,
|
|
820
|
+
'✅ Success',
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
discordClient.destroy()
|
|
824
|
+
process.exit(0)
|
|
825
|
+
} catch (error) {
|
|
826
|
+
cliLogger.error(
|
|
827
|
+
'Error:',
|
|
828
|
+
error instanceof Error ? error.message : String(error),
|
|
829
|
+
)
|
|
830
|
+
process.exit(EXIT_NO_RESTART)
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
cli
|
|
835
|
+
.command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
|
|
836
|
+
.action(async () => {
|
|
837
|
+
try {
|
|
838
|
+
const require = createRequire(import.meta.url)
|
|
839
|
+
const pluginSrc = require.resolve('./opencode-plugin.ts')
|
|
840
|
+
const commandSrc = require.resolve('./opencode-command.md')
|
|
841
|
+
|
|
842
|
+
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
|
|
843
|
+
const pluginDir = path.join(opencodeConfig, 'plugin')
|
|
844
|
+
const commandDir = path.join(opencodeConfig, 'command')
|
|
845
|
+
|
|
846
|
+
fs.mkdirSync(pluginDir, { recursive: true })
|
|
847
|
+
fs.mkdirSync(commandDir, { recursive: true })
|
|
848
|
+
|
|
849
|
+
const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts')
|
|
850
|
+
const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
|
|
851
|
+
|
|
852
|
+
fs.copyFileSync(pluginSrc, pluginDest)
|
|
853
|
+
fs.copyFileSync(commandSrc, commandDest)
|
|
854
|
+
|
|
855
|
+
note(
|
|
856
|
+
`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`,
|
|
857
|
+
'✅ Installed',
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
process.exit(0)
|
|
861
|
+
} catch (error) {
|
|
862
|
+
cliLogger.error(
|
|
863
|
+
'Error:',
|
|
864
|
+
error instanceof Error ? error.message : String(error),
|
|
865
|
+
)
|
|
866
|
+
process.exit(EXIT_NO_RESTART)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
|
|
658
870
|
cli.help()
|
|
659
871
|
cli.parse()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimaki Discord Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Adds /send-to-kimaki-discord command that sends the current session to Discord.
|
|
5
|
+
*
|
|
6
|
+
* Installation:
|
|
7
|
+
* kimaki install-plugin
|
|
8
|
+
*
|
|
9
|
+
* Use in OpenCode TUI:
|
|
10
|
+
* /send-to-kimaki-discord
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
14
|
+
|
|
15
|
+
export const KimakiDiscordPlugin: Plugin = async ({
|
|
16
|
+
client,
|
|
17
|
+
$,
|
|
18
|
+
directory,
|
|
19
|
+
}) => {
|
|
20
|
+
return {
|
|
21
|
+
event: async ({ event }) => {
|
|
22
|
+
if (event.type !== 'command.executed') {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { name, sessionID } = event.properties as {
|
|
27
|
+
name: string
|
|
28
|
+
sessionID: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (name !== 'send-to-kimaki-discord') {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!sessionID) {
|
|
36
|
+
await client.tui.showToast({
|
|
37
|
+
body: { message: 'No session ID available', variant: 'error' },
|
|
38
|
+
})
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await client.tui.showToast({
|
|
43
|
+
body: { message: 'Creating Discord thread...', variant: 'info' },
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result =
|
|
48
|
+
await $`npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text()
|
|
49
|
+
|
|
50
|
+
const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/)
|
|
51
|
+
const url = urlMatch ? urlMatch[0] : null
|
|
52
|
+
|
|
53
|
+
await client.tui.showToast({
|
|
54
|
+
body: {
|
|
55
|
+
message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
|
|
56
|
+
variant: 'success',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
const message =
|
|
61
|
+
error.stderr?.toString().trim() ||
|
|
62
|
+
error.stdout?.toString().trim() ||
|
|
63
|
+
error.message ||
|
|
64
|
+
String(error)
|
|
65
|
+
|
|
66
|
+
await client.tui.showToast({
|
|
67
|
+
body: {
|
|
68
|
+
message: `Failed: ${message.slice(0, 100)}`,
|
|
69
|
+
variant: 'error',
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/voice.ts
CHANGED
|
@@ -42,7 +42,17 @@ export async function transcribeAudio({
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Build the transcription prompt
|
|
45
|
-
let transcriptionPrompt = `
|
|
45
|
+
let transcriptionPrompt = `Transcribe this audio accurately. The transcription will be sent to a coding agent (like Claude Code) to execute programming tasks.
|
|
46
|
+
|
|
47
|
+
Assume the speaker is using technical and programming terminology: file paths, function names, CLI commands, package names, API names, programming concepts, etc. Prioritize technical accuracy over literal transcription - if a word sounds like a common programming term, prefer that interpretation.
|
|
48
|
+
|
|
49
|
+
If the spoken message is unclear or ambiguous, rephrase it to better convey the intended meaning for a coding agent. The goal is effective communication of the user's programming intent, not a word-for-word transcription.
|
|
50
|
+
|
|
51
|
+
Here are relevant filenames and context that may appear in the audio:
|
|
52
|
+
<context>
|
|
53
|
+
${prompt}
|
|
54
|
+
</context>
|
|
55
|
+
`
|
|
46
56
|
if (language) {
|
|
47
57
|
transcriptionPrompt += `\nThe audio is in ${language}.`
|
|
48
58
|
}
|