shuvmaki 0.4.26
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 +70 -0
- package/dist/ai-tool-to-genai.js +210 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +97 -0
- package/dist/cli.js +709 -0
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +184 -0
- package/dist/discord-bot.js +384 -0
- package/dist/discord-utils.js +217 -0
- package/dist/escape-backticks.test.js +410 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +297 -0
- package/dist/genai.js +232 -0
- package/dist/interaction-handler.js +144 -0
- package/dist/logger.js +51 -0
- package/dist/markdown.js +310 -0
- package/dist/markdown.test.js +262 -0
- package/dist/message-formatting.js +273 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode.js +216 -0
- package/dist/session-handler.js +580 -0
- package/dist/system-message.js +61 -0
- package/dist/tools.js +356 -0
- package/dist/utils.js +85 -0
- package/dist/voice-handler.js +541 -0
- package/dist/voice.js +314 -0
- package/dist/worker-types.js +4 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +60 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +255 -0
- package/src/channel-management.ts +161 -0
- package/src/cli.ts +1010 -0
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/commands/fork.ts +257 -0
- package/src/commands/model.ts +402 -0
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +220 -0
- package/src/discord-bot.ts +513 -0
- package/src/discord-utils.ts +282 -0
- package/src/escape-backticks.test.ts +447 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +110 -0
- package/src/genai-worker-wrapper.ts +160 -0
- package/src/genai-worker.ts +366 -0
- package/src/genai.ts +321 -0
- package/src/interaction-handler.ts +187 -0
- package/src/logger.ts +57 -0
- package/src/markdown.test.ts +358 -0
- package/src/markdown.ts +365 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +340 -0
- package/src/openai-realtime.ts +363 -0
- package/src/opencode.ts +277 -0
- package/src/session-handler.ts +758 -0
- package/src/system-message.ts +62 -0
- package/src/tools.ts +428 -0
- package/src/utils.ts +118 -0
- package/src/voice-handler.ts +760 -0
- package/src/voice.ts +432 -0
- package/src/worker-types.ts +66 -0
- package/src/xml.test.ts +37 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
import { ChannelType } from 'discord.js';
|
|
4
|
+
import { extractTagsArrays } from '../xml.js';
|
|
5
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
6
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { getDatabase } from '../database.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
const userCommandLogger = createLogger('USER_CMD');
|
|
11
|
+
export const handleUserCommand = async ({ command, appId, }) => {
|
|
12
|
+
const discordCommandName = command.commandName;
|
|
13
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
14
|
+
const commandName = discordCommandName.replace(/-cmd$/, '');
|
|
15
|
+
const args = command.options.getString('arguments') || '';
|
|
16
|
+
userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
|
|
17
|
+
const channel = command.channel;
|
|
18
|
+
userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
|
|
19
|
+
const isThread = channel && [
|
|
20
|
+
ChannelType.PublicThread,
|
|
21
|
+
ChannelType.PrivateThread,
|
|
22
|
+
ChannelType.AnnouncementThread,
|
|
23
|
+
].includes(channel.type);
|
|
24
|
+
const isTextChannel = channel?.type === ChannelType.GuildText;
|
|
25
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
26
|
+
await command.reply({
|
|
27
|
+
content: 'This command can only be used in text channels or threads',
|
|
28
|
+
ephemeral: true,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let projectDirectory;
|
|
33
|
+
let channelAppId;
|
|
34
|
+
let textChannel = null;
|
|
35
|
+
let thread = null;
|
|
36
|
+
if (isThread) {
|
|
37
|
+
// Running in an existing thread - get project directory from parent channel
|
|
38
|
+
thread = channel;
|
|
39
|
+
textChannel = thread.parent;
|
|
40
|
+
// Verify this thread has an existing session
|
|
41
|
+
const row = getDatabase()
|
|
42
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
43
|
+
.get(thread.id);
|
|
44
|
+
if (!row) {
|
|
45
|
+
await command.reply({
|
|
46
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
47
|
+
ephemeral: true,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (textChannel?.topic) {
|
|
52
|
+
const extracted = extractTagsArrays({
|
|
53
|
+
xml: textChannel.topic,
|
|
54
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
55
|
+
});
|
|
56
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
57
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Running in a text channel - will create a new thread
|
|
62
|
+
textChannel = channel;
|
|
63
|
+
if (textChannel.topic) {
|
|
64
|
+
const extracted = extractTagsArrays({
|
|
65
|
+
xml: textChannel.topic,
|
|
66
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
67
|
+
});
|
|
68
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
69
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (channelAppId && channelAppId !== appId) {
|
|
73
|
+
await command.reply({
|
|
74
|
+
content: 'This channel is not configured for this bot',
|
|
75
|
+
ephemeral: true,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!projectDirectory) {
|
|
80
|
+
await command.reply({
|
|
81
|
+
content: 'This channel is not configured with a project directory',
|
|
82
|
+
ephemeral: true,
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
87
|
+
await command.reply({
|
|
88
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
89
|
+
ephemeral: true,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await command.deferReply({ ephemeral: false });
|
|
94
|
+
try {
|
|
95
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
96
|
+
const commandPayload = { name: commandName, arguments: args };
|
|
97
|
+
if (isThread && thread) {
|
|
98
|
+
// Running in existing thread - just send the command
|
|
99
|
+
await command.editReply(`Running /${commandName}...`);
|
|
100
|
+
await handleOpencodeSession({
|
|
101
|
+
prompt: '', // Not used when command is set
|
|
102
|
+
thread,
|
|
103
|
+
projectDirectory,
|
|
104
|
+
channelId: textChannel?.id,
|
|
105
|
+
command: commandPayload,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else if (textChannel) {
|
|
109
|
+
// Running in text channel - create a new thread
|
|
110
|
+
const starterMessage = await textChannel.send({
|
|
111
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
112
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
113
|
+
});
|
|
114
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
|
|
115
|
+
const newThread = await starterMessage.startThread({
|
|
116
|
+
name: threadName.slice(0, 100),
|
|
117
|
+
autoArchiveDuration: 1440,
|
|
118
|
+
reason: `OpenCode command: ${commandName}`,
|
|
119
|
+
});
|
|
120
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
121
|
+
await handleOpencodeSession({
|
|
122
|
+
prompt: '', // Not used when command is set
|
|
123
|
+
thread: newThread,
|
|
124
|
+
projectDirectory,
|
|
125
|
+
channelId: textChannel.id,
|
|
126
|
+
command: commandPayload,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error);
|
|
132
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
133
|
+
if (command.deferred) {
|
|
134
|
+
await command.editReply({
|
|
135
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await command.reply({
|
|
140
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
141
|
+
ephemeral: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// SQLite database manager for persistent bot state.
|
|
2
|
+
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
+
// API keys, and model preferences in ~/.kimaki/discord-sessions.db.
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
const dbLogger = createLogger('DB');
|
|
10
|
+
let db = null;
|
|
11
|
+
export function getDatabase() {
|
|
12
|
+
if (!db) {
|
|
13
|
+
const kimakiDir = path.join(os.homedir(), '.kimaki');
|
|
14
|
+
try {
|
|
15
|
+
fs.mkdirSync(kimakiDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
dbLogger.error('Failed to create ~/.kimaki directory:', error);
|
|
19
|
+
}
|
|
20
|
+
const dbPath = path.join(kimakiDir, 'discord-sessions.db');
|
|
21
|
+
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
22
|
+
db = new Database(dbPath);
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
25
|
+
thread_id TEXT PRIMARY KEY,
|
|
26
|
+
session_id TEXT NOT NULL,
|
|
27
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS part_messages (
|
|
32
|
+
part_id TEXT PRIMARY KEY,
|
|
33
|
+
message_id TEXT NOT NULL,
|
|
34
|
+
thread_id TEXT NOT NULL,
|
|
35
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
40
|
+
app_id TEXT PRIMARY KEY,
|
|
41
|
+
token TEXT NOT NULL,
|
|
42
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
43
|
+
)
|
|
44
|
+
`);
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS channel_directories (
|
|
47
|
+
channel_id TEXT PRIMARY KEY,
|
|
48
|
+
directory TEXT NOT NULL,
|
|
49
|
+
channel_type TEXT NOT NULL,
|
|
50
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
db.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
55
|
+
app_id TEXT PRIMARY KEY,
|
|
56
|
+
gemini_api_key TEXT,
|
|
57
|
+
xai_api_key TEXT,
|
|
58
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
runModelMigrations(db);
|
|
62
|
+
}
|
|
63
|
+
return db;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run migrations for model preferences tables.
|
|
67
|
+
* Called on startup and can be called on-demand.
|
|
68
|
+
*/
|
|
69
|
+
export function runModelMigrations(database) {
|
|
70
|
+
const targetDb = database || getDatabase();
|
|
71
|
+
targetDb.exec(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS channel_models (
|
|
73
|
+
channel_id TEXT PRIMARY KEY,
|
|
74
|
+
model_id TEXT NOT NULL,
|
|
75
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
76
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
77
|
+
)
|
|
78
|
+
`);
|
|
79
|
+
targetDb.exec(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS session_models (
|
|
81
|
+
session_id TEXT PRIMARY KEY,
|
|
82
|
+
model_id TEXT NOT NULL,
|
|
83
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
84
|
+
)
|
|
85
|
+
`);
|
|
86
|
+
targetDb.exec(`
|
|
87
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
88
|
+
channel_id TEXT PRIMARY KEY,
|
|
89
|
+
agent_name TEXT NOT NULL,
|
|
90
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
91
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
92
|
+
)
|
|
93
|
+
`);
|
|
94
|
+
targetDb.exec(`
|
|
95
|
+
CREATE TABLE IF NOT EXISTS session_agents (
|
|
96
|
+
session_id TEXT PRIMARY KEY,
|
|
97
|
+
agent_name TEXT NOT NULL,
|
|
98
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
99
|
+
)
|
|
100
|
+
`);
|
|
101
|
+
dbLogger.log('Model preferences migrations complete');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get the model preference for a channel.
|
|
105
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
106
|
+
*/
|
|
107
|
+
export function getChannelModel(channelId) {
|
|
108
|
+
const db = getDatabase();
|
|
109
|
+
const row = db
|
|
110
|
+
.prepare('SELECT model_id FROM channel_models WHERE channel_id = ?')
|
|
111
|
+
.get(channelId);
|
|
112
|
+
return row?.model_id;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Set the model preference for a channel.
|
|
116
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
117
|
+
*/
|
|
118
|
+
export function setChannelModel(channelId, modelId) {
|
|
119
|
+
const db = getDatabase();
|
|
120
|
+
db.prepare(`INSERT INTO channel_models (channel_id, model_id, updated_at)
|
|
121
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
122
|
+
ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, modelId, modelId);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get the model preference for a session.
|
|
126
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
127
|
+
*/
|
|
128
|
+
export function getSessionModel(sessionId) {
|
|
129
|
+
const db = getDatabase();
|
|
130
|
+
const row = db
|
|
131
|
+
.prepare('SELECT model_id FROM session_models WHERE session_id = ?')
|
|
132
|
+
.get(sessionId);
|
|
133
|
+
return row?.model_id;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Set the model preference for a session.
|
|
137
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
138
|
+
*/
|
|
139
|
+
export function setSessionModel(sessionId, modelId) {
|
|
140
|
+
const db = getDatabase();
|
|
141
|
+
db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get the agent preference for a channel.
|
|
145
|
+
*/
|
|
146
|
+
export function getChannelAgent(channelId) {
|
|
147
|
+
const db = getDatabase();
|
|
148
|
+
const row = db
|
|
149
|
+
.prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
|
|
150
|
+
.get(channelId);
|
|
151
|
+
return row?.agent_name;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Set the agent preference for a channel.
|
|
155
|
+
*/
|
|
156
|
+
export function setChannelAgent(channelId, agentName) {
|
|
157
|
+
const db = getDatabase();
|
|
158
|
+
db.prepare(`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
|
|
159
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
160
|
+
ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, agentName, agentName);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get the agent preference for a session.
|
|
164
|
+
*/
|
|
165
|
+
export function getSessionAgent(sessionId) {
|
|
166
|
+
const db = getDatabase();
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
|
|
169
|
+
.get(sessionId);
|
|
170
|
+
return row?.agent_name;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Set the agent preference for a session.
|
|
174
|
+
*/
|
|
175
|
+
export function setSessionAgent(sessionId, agentName) {
|
|
176
|
+
const db = getDatabase();
|
|
177
|
+
db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
|
|
178
|
+
}
|
|
179
|
+
export function closeDatabase() {
|
|
180
|
+
if (db) {
|
|
181
|
+
db.close();
|
|
182
|
+
db = null;
|
|
183
|
+
}
|
|
184
|
+
}
|