kimaki 0.4.46 ā 0.4.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +69 -21
- package/dist/commands/abort.js +4 -2
- package/dist/commands/add-project.js +2 -2
- package/dist/commands/agent.js +4 -4
- package/dist/commands/ask-question.js +9 -8
- package/dist/commands/compact.js +126 -0
- package/dist/commands/create-new-project.js +60 -30
- package/dist/commands/fork.js +3 -3
- package/dist/commands/merge-worktree.js +23 -10
- package/dist/commands/model.js +5 -5
- package/dist/commands/permissions.js +5 -3
- package/dist/commands/queue.js +2 -2
- package/dist/commands/remove-project.js +2 -2
- package/dist/commands/resume.js +2 -2
- package/dist/commands/session.js +6 -3
- package/dist/commands/share.js +2 -2
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/user-command.js +2 -2
- package/dist/commands/verbosity.js +5 -5
- package/dist/commands/worktree-settings.js +2 -2
- package/dist/commands/worktree.js +18 -8
- package/dist/config.js +7 -0
- package/dist/database.js +10 -7
- package/dist/discord-bot.js +30 -12
- package/dist/discord-utils.js +2 -2
- package/dist/genai-worker-wrapper.js +3 -3
- package/dist/genai-worker.js +2 -2
- package/dist/genai.js +2 -2
- package/dist/interaction-handler.js +6 -2
- package/dist/logger.js +57 -9
- package/dist/markdown.js +2 -2
- package/dist/message-formatting.js +91 -6
- package/dist/openai-realtime.js +2 -2
- package/dist/opencode.js +19 -25
- package/dist/session-handler.js +89 -29
- package/dist/system-message.js +11 -9
- package/dist/tools.js +3 -2
- package/dist/utils.js +1 -0
- package/dist/voice-handler.js +2 -2
- package/dist/voice.js +2 -2
- package/dist/worktree-utils.js +91 -7
- package/dist/xml.js +2 -2
- package/package.json +3 -3
- package/src/cli.ts +108 -21
- package/src/commands/abort.ts +4 -2
- package/src/commands/add-project.ts +2 -2
- package/src/commands/agent.ts +4 -4
- package/src/commands/ask-question.ts +9 -8
- package/src/commands/compact.ts +148 -0
- package/src/commands/create-new-project.ts +87 -36
- package/src/commands/fork.ts +3 -3
- package/src/commands/merge-worktree.ts +47 -10
- package/src/commands/model.ts +5 -5
- package/src/commands/permissions.ts +6 -2
- package/src/commands/queue.ts +2 -2
- package/src/commands/remove-project.ts +2 -2
- package/src/commands/resume.ts +2 -2
- package/src/commands/session.ts +6 -3
- package/src/commands/share.ts +2 -2
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/user-command.ts +2 -2
- package/src/commands/verbosity.ts +5 -5
- package/src/commands/worktree-settings.ts +2 -2
- package/src/commands/worktree.ts +20 -7
- package/src/config.ts +14 -0
- package/src/database.ts +13 -7
- package/src/discord-bot.ts +45 -12
- package/src/discord-utils.ts +2 -2
- package/src/genai-worker-wrapper.ts +3 -3
- package/src/genai-worker.ts +2 -2
- package/src/genai.ts +2 -2
- package/src/interaction-handler.ts +7 -2
- package/src/logger.ts +64 -10
- package/src/markdown.ts +2 -2
- package/src/message-formatting.ts +100 -6
- package/src/openai-realtime.ts +2 -2
- package/src/opencode.ts +19 -26
- package/src/session-handler.ts +102 -29
- package/src/system-message.ts +11 -9
- package/src/tools.ts +3 -2
- package/src/utils.ts +1 -0
- package/src/voice-handler.ts +2 -2
- package/src/voice.ts +2 -2
- package/src/worktree-utils.ts +111 -7
- package/src/xml.ts +2 -2
package/dist/cli.js
CHANGED
|
@@ -10,13 +10,33 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuild
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import * as errore from 'errore';
|
|
13
|
-
import { createLogger } from './logger.js';
|
|
13
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
14
14
|
import { uploadFilesToDiscord } from './discord-utils.js';
|
|
15
15
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
16
16
|
import http from 'node:http';
|
|
17
|
-
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
17
|
+
import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js';
|
|
18
18
|
import { sanitizeAgentName } from './commands/agent.js';
|
|
19
|
-
const cliLogger = createLogger(
|
|
19
|
+
const cliLogger = createLogger(LogPrefix.CLI);
|
|
20
|
+
// Spawn caffeinate on macOS to prevent system sleep while bot is running.
|
|
21
|
+
// Not detached, so it dies automatically with the parent process.
|
|
22
|
+
function startCaffeinate() {
|
|
23
|
+
if (process.platform !== 'darwin') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const proc = spawn('caffeinate', ['-i'], {
|
|
28
|
+
stdio: 'ignore',
|
|
29
|
+
detached: false,
|
|
30
|
+
});
|
|
31
|
+
proc.on('error', (err) => {
|
|
32
|
+
cliLogger.warn('Failed to start caffeinate:', err.message);
|
|
33
|
+
});
|
|
34
|
+
cliLogger.log('Started caffeinate to prevent system sleep');
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
cliLogger.warn('Failed to spawn caffeinate:', err instanceof Error ? err.message : String(err));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
20
40
|
const cli = cac('kimaki');
|
|
21
41
|
process.title = 'kimaki';
|
|
22
42
|
async function killProcessOnPort(port) {
|
|
@@ -86,7 +106,8 @@ async function checkSingleInstance() {
|
|
|
86
106
|
});
|
|
87
107
|
}
|
|
88
108
|
}
|
|
89
|
-
catch {
|
|
109
|
+
catch (error) {
|
|
110
|
+
cliLogger.debug('Lock port check failed:', error instanceof Error ? error.message : String(error));
|
|
90
111
|
cliLogger.debug('No other kimaki instance detected on lock port');
|
|
91
112
|
}
|
|
92
113
|
}
|
|
@@ -217,6 +238,10 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
|
|
|
217
238
|
.setName('abort')
|
|
218
239
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
219
240
|
.toJSON(),
|
|
241
|
+
new SlashCommandBuilder()
|
|
242
|
+
.setName('compact')
|
|
243
|
+
.setDescription('Compact the session context by summarizing conversation history')
|
|
244
|
+
.toJSON(),
|
|
220
245
|
new SlashCommandBuilder()
|
|
221
246
|
.setName('stop')
|
|
222
247
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
@@ -375,11 +400,17 @@ async function backgroundInit({ currentDir, token, appId, }) {
|
|
|
375
400
|
getClient()
|
|
376
401
|
.command.list({ query: { directory: currentDir } })
|
|
377
402
|
.then((r) => r.data || [])
|
|
378
|
-
.catch(() =>
|
|
403
|
+
.catch((error) => {
|
|
404
|
+
cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.message : String(error));
|
|
405
|
+
return [];
|
|
406
|
+
}),
|
|
379
407
|
getClient()
|
|
380
408
|
.app.agents({ query: { directory: currentDir } })
|
|
381
409
|
.then((r) => r.data || [])
|
|
382
|
-
.catch(() =>
|
|
410
|
+
.catch((error) => {
|
|
411
|
+
cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.message : String(error));
|
|
412
|
+
return [];
|
|
413
|
+
}),
|
|
383
414
|
]);
|
|
384
415
|
await registerCommands({ token, appId, userCommands, agents });
|
|
385
416
|
cliLogger.log('Slash commands registered!');
|
|
@@ -389,6 +420,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
|
|
|
389
420
|
}
|
|
390
421
|
}
|
|
391
422
|
async function run({ restart, addChannels, useWorktrees }) {
|
|
423
|
+
startCaffeinate();
|
|
392
424
|
const forceSetup = Boolean(restart);
|
|
393
425
|
intro('š¤ Discord Bot Setup');
|
|
394
426
|
// Step 0: Check if OpenCode CLI is available
|
|
@@ -423,7 +455,8 @@ async function run({ restart, addChannels, useWorktrees }) {
|
|
|
423
455
|
fs.accessSync(p, fs.constants.F_OK);
|
|
424
456
|
return true;
|
|
425
457
|
}
|
|
426
|
-
catch {
|
|
458
|
+
catch (error) {
|
|
459
|
+
cliLogger.debug(`OpenCode path not found at ${p}:`, error instanceof Error ? error.message : String(error));
|
|
427
460
|
return false;
|
|
428
461
|
}
|
|
429
462
|
});
|
|
@@ -651,11 +684,17 @@ async function run({ restart, addChannels, useWorktrees }) {
|
|
|
651
684
|
getClient()
|
|
652
685
|
.command.list({ query: { directory: currentDir } })
|
|
653
686
|
.then((r) => r.data || [])
|
|
654
|
-
.catch(() =>
|
|
687
|
+
.catch((error) => {
|
|
688
|
+
cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.message : String(error));
|
|
689
|
+
return [];
|
|
690
|
+
}),
|
|
655
691
|
getClient()
|
|
656
692
|
.app.agents({ query: { directory: currentDir } })
|
|
657
693
|
.then((r) => r.data || [])
|
|
658
|
-
.catch(() =>
|
|
694
|
+
.catch((error) => {
|
|
695
|
+
cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.message : String(error));
|
|
696
|
+
return [];
|
|
697
|
+
}),
|
|
659
698
|
]);
|
|
660
699
|
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
661
700
|
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
@@ -766,6 +805,7 @@ cli
|
|
|
766
805
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
767
806
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
768
807
|
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
808
|
+
.option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
|
|
769
809
|
.action(async (options) => {
|
|
770
810
|
try {
|
|
771
811
|
// Set data directory early, before any database access
|
|
@@ -773,6 +813,14 @@ cli
|
|
|
773
813
|
setDataDir(options.dataDir);
|
|
774
814
|
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
775
815
|
}
|
|
816
|
+
if (options.verbosity) {
|
|
817
|
+
if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
|
|
818
|
+
cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`);
|
|
819
|
+
process.exit(EXIT_NO_RESTART);
|
|
820
|
+
}
|
|
821
|
+
setDefaultVerbosity(options.verbosity);
|
|
822
|
+
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
823
|
+
}
|
|
776
824
|
if (options.installUrl) {
|
|
777
825
|
const db = getDatabase();
|
|
778
826
|
const existingBot = db
|
|
@@ -895,8 +943,8 @@ cli
|
|
|
895
943
|
.get();
|
|
896
944
|
appId = botRow?.app_id;
|
|
897
945
|
}
|
|
898
|
-
catch {
|
|
899
|
-
|
|
946
|
+
catch (error) {
|
|
947
|
+
cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
|
|
900
948
|
}
|
|
901
949
|
}
|
|
902
950
|
}
|
|
@@ -992,8 +1040,8 @@ cli
|
|
|
992
1040
|
return ch.guild;
|
|
993
1041
|
}
|
|
994
1042
|
}
|
|
995
|
-
catch {
|
|
996
|
-
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
|
|
997
1045
|
}
|
|
998
1046
|
}
|
|
999
1047
|
// Fall back to first guild the bot is in
|
|
@@ -1174,8 +1222,8 @@ cli
|
|
|
1174
1222
|
.get();
|
|
1175
1223
|
appId = botRow?.app_id;
|
|
1176
1224
|
}
|
|
1177
|
-
catch {
|
|
1178
|
-
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
|
|
1179
1227
|
}
|
|
1180
1228
|
}
|
|
1181
1229
|
}
|
|
@@ -1245,8 +1293,8 @@ cli
|
|
|
1245
1293
|
throw new Error('Channel has no guild');
|
|
1246
1294
|
}
|
|
1247
1295
|
}
|
|
1248
|
-
catch {
|
|
1249
|
-
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
|
|
1250
1298
|
const firstGuild = client.guilds.cache.first();
|
|
1251
1299
|
if (!firstGuild) {
|
|
1252
1300
|
s.stop('No guild found');
|
|
@@ -1285,13 +1333,13 @@ cli
|
|
|
1285
1333
|
process.exit(0);
|
|
1286
1334
|
}
|
|
1287
1335
|
}
|
|
1288
|
-
catch {
|
|
1289
|
-
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.message : String(error));
|
|
1290
1338
|
}
|
|
1291
1339
|
}
|
|
1292
1340
|
}
|
|
1293
|
-
catch {
|
|
1294
|
-
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.message : String(error));
|
|
1295
1343
|
}
|
|
1296
1344
|
s.message(`Creating channels in ${guild.name}...`);
|
|
1297
1345
|
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
package/dist/commands/abort.js
CHANGED
|
@@ -4,9 +4,9 @@ import { getDatabase } from '../database.js';
|
|
|
4
4
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
5
|
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
6
|
import { abortControllers } from '../session-handler.js';
|
|
7
|
-
import { createLogger } from '../logger.js';
|
|
7
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import * as errore from 'errore';
|
|
9
|
-
const logger = createLogger(
|
|
9
|
+
const logger = createLogger(LogPrefix.ABORT);
|
|
10
10
|
export async function handleAbortCommand({ command }) {
|
|
11
11
|
const channel = command.channel;
|
|
12
12
|
if (!channel) {
|
|
@@ -54,6 +54,7 @@ export async function handleAbortCommand({ command }) {
|
|
|
54
54
|
const sessionId = row.session_id;
|
|
55
55
|
const existingController = abortControllers.get(sessionId);
|
|
56
56
|
if (existingController) {
|
|
57
|
+
logger.log(`[ABORT] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - user ran /abort command`);
|
|
57
58
|
existingController.abort(new Error('User requested abort'));
|
|
58
59
|
abortControllers.delete(sessionId);
|
|
59
60
|
}
|
|
@@ -67,6 +68,7 @@ export async function handleAbortCommand({ command }) {
|
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
69
70
|
try {
|
|
71
|
+
logger.log(`[ABORT-API] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - sending API abort from /abort command`);
|
|
70
72
|
await getClient().session.abort({
|
|
71
73
|
path: { id: sessionId },
|
|
72
74
|
});
|
|
@@ -4,10 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import { getDatabase } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
6
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
|
-
import { createLogger } from '../logger.js';
|
|
7
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import { abbreviatePath } from '../utils.js';
|
|
9
9
|
import * as errore from 'errore';
|
|
10
|
-
const logger = createLogger(
|
|
10
|
+
const logger = createLogger(LogPrefix.ADD_PROJECT);
|
|
11
11
|
export async function handleAddProjectCommand({ command, appId }) {
|
|
12
12
|
await command.deferReply({ ephemeral: false });
|
|
13
13
|
const projectId = command.options.getString('project', true);
|
package/dist/commands/agent.js
CHANGED
|
@@ -5,9 +5,9 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
8
|
-
import { createLogger } from '../logger.js';
|
|
8
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
9
|
import * as errore from 'errore';
|
|
10
|
-
const agentLogger = createLogger(
|
|
10
|
+
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
11
11
|
const pendingAgentContexts = new Map();
|
|
12
12
|
/**
|
|
13
13
|
* Sanitize an agent name to be a valid Discord command name component.
|
|
@@ -181,7 +181,7 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
181
181
|
}
|
|
182
182
|
else {
|
|
183
183
|
await interaction.editReply({
|
|
184
|
-
content: `Agent preference set for this channel: **${selectedAgent}**\
|
|
184
|
+
content: `Agent preference set for this channel: **${selectedAgent}**\nAll new sessions in this channel will use this agent.`,
|
|
185
185
|
components: [],
|
|
186
186
|
});
|
|
187
187
|
}
|
|
@@ -237,7 +237,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
237
237
|
}
|
|
238
238
|
else {
|
|
239
239
|
await command.editReply({
|
|
240
|
-
content: `Switched to **${matchingAgent.name}** agent for this channel\
|
|
240
|
+
content: `Switched to **${matchingAgent.name}** agent for this channel\nAll new sessions will use this agent.`,
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
}
|
|
@@ -5,8 +5,8 @@ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder,
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
7
|
import { getOpencodeClientV2 } from '../opencode.js';
|
|
8
|
-
import { createLogger } from '../logger.js';
|
|
9
|
-
const logger = createLogger(
|
|
8
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
|
+
const logger = createLogger(LogPrefix.ASK_QUESTION);
|
|
10
10
|
// Store pending question contexts by hash
|
|
11
11
|
export const pendingQuestionContexts = new Map();
|
|
12
12
|
/**
|
|
@@ -178,9 +178,9 @@ export function parseAskUserQuestionTool(part) {
|
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
180
|
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
181
|
-
* Sends
|
|
181
|
+
* Sends the user's message as the answer to OpenCode so the model sees their actual response.
|
|
182
182
|
*/
|
|
183
|
-
export async function cancelPendingQuestion(threadId) {
|
|
183
|
+
export async function cancelPendingQuestion(threadId, userMessage) {
|
|
184
184
|
// Find pending question for this thread
|
|
185
185
|
let contextHash;
|
|
186
186
|
let context;
|
|
@@ -199,18 +199,19 @@ export async function cancelPendingQuestion(threadId) {
|
|
|
199
199
|
if (!clientV2) {
|
|
200
200
|
throw new Error('OpenCode server not found for directory');
|
|
201
201
|
}
|
|
202
|
-
//
|
|
202
|
+
// Use user's message as answer if provided, otherwise mark as "Other"
|
|
203
|
+
const customAnswer = userMessage || 'Other';
|
|
203
204
|
const answers = context.questions.map((_, i) => {
|
|
204
|
-
return context.answers[i] || [
|
|
205
|
+
return context.answers[i] || [customAnswer];
|
|
205
206
|
});
|
|
206
207
|
await clientV2.question.reply({
|
|
207
208
|
requestID: context.requestId,
|
|
208
209
|
answers,
|
|
209
210
|
});
|
|
210
|
-
logger.log(`
|
|
211
|
+
logger.log(`Answered question ${context.requestId} with user message`);
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
213
|
-
logger.error('Failed to
|
|
214
|
+
logger.error('Failed to answer question:', error);
|
|
214
215
|
}
|
|
215
216
|
// Clean up regardless of whether the API call succeeded
|
|
216
217
|
pendingQuestionContexts.delete(contextHash);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// /compact command - Trigger context compaction (summarization) for the current session.
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
const logger = createLogger(LogPrefix.COMPACT);
|
|
8
|
+
export async function handleCompactCommand({ command }) {
|
|
9
|
+
const channel = command.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await command.reply({
|
|
12
|
+
content: 'This command can only be used in a channel',
|
|
13
|
+
ephemeral: true,
|
|
14
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const isThread = [
|
|
19
|
+
ChannelType.PublicThread,
|
|
20
|
+
ChannelType.PrivateThread,
|
|
21
|
+
ChannelType.AnnouncementThread,
|
|
22
|
+
].includes(channel.type);
|
|
23
|
+
if (!isThread) {
|
|
24
|
+
await command.reply({
|
|
25
|
+
content: 'This command can only be used in a thread with an active session',
|
|
26
|
+
ephemeral: true,
|
|
27
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const textChannel = await resolveTextChannel(channel);
|
|
32
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
33
|
+
if (!directory) {
|
|
34
|
+
await command.reply({
|
|
35
|
+
content: 'Could not determine project directory for this channel',
|
|
36
|
+
ephemeral: true,
|
|
37
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const row = getDatabase()
|
|
42
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
43
|
+
.get(channel.id);
|
|
44
|
+
if (!row?.session_id) {
|
|
45
|
+
await command.reply({
|
|
46
|
+
content: 'No active session in this thread',
|
|
47
|
+
ephemeral: true,
|
|
48
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const sessionId = row.session_id;
|
|
53
|
+
// Ensure server is running for this directory
|
|
54
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
|
+
await command.reply({
|
|
57
|
+
content: `Failed to compact: ${getClient.message}`,
|
|
58
|
+
ephemeral: true,
|
|
59
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const clientV2 = getOpencodeClientV2(directory);
|
|
64
|
+
if (!clientV2) {
|
|
65
|
+
await command.reply({
|
|
66
|
+
content: 'Failed to get OpenCode client',
|
|
67
|
+
ephemeral: true,
|
|
68
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Defer reply since compaction may take a moment
|
|
73
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
74
|
+
try {
|
|
75
|
+
// Get session messages to find the model from the last user message
|
|
76
|
+
const messagesResult = await clientV2.session.messages({
|
|
77
|
+
sessionID: sessionId,
|
|
78
|
+
directory,
|
|
79
|
+
});
|
|
80
|
+
if (messagesResult.error || !messagesResult.data) {
|
|
81
|
+
logger.error('[COMPACT] Failed to get messages:', messagesResult.error);
|
|
82
|
+
await command.editReply({
|
|
83
|
+
content: 'Failed to compact: Could not retrieve session messages',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Find the last user message to get the model
|
|
88
|
+
const lastUserMessage = [...messagesResult.data]
|
|
89
|
+
.reverse()
|
|
90
|
+
.find((msg) => msg.info.role === 'user');
|
|
91
|
+
if (!lastUserMessage || lastUserMessage.info.role !== 'user') {
|
|
92
|
+
await command.editReply({
|
|
93
|
+
content: 'Failed to compact: No user message found in session',
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { providerID, modelID } = lastUserMessage.info.model;
|
|
98
|
+
const result = await clientV2.session.summarize({
|
|
99
|
+
sessionID: sessionId,
|
|
100
|
+
directory,
|
|
101
|
+
providerID,
|
|
102
|
+
modelID,
|
|
103
|
+
auto: false,
|
|
104
|
+
});
|
|
105
|
+
if (result.error) {
|
|
106
|
+
logger.error('[COMPACT] Error:', result.error);
|
|
107
|
+
const errorMessage = 'data' in result.error && result.error.data
|
|
108
|
+
? result.error.data.message || 'Unknown error'
|
|
109
|
+
: 'Unknown error';
|
|
110
|
+
await command.editReply({
|
|
111
|
+
content: `Failed to compact: ${errorMessage}`,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await command.editReply({
|
|
116
|
+
content: `š¦ Session **compacted** successfully`,
|
|
117
|
+
});
|
|
118
|
+
logger.log(`Session ${sessionId} compacted by user`);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
logger.error('[COMPACT] Error:', error);
|
|
122
|
+
await command.editReply({
|
|
123
|
+
content: `Failed to compact: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,13 +1,51 @@
|
|
|
1
1
|
// /create-new-project command - Create a new project folder, initialize git, and start a session.
|
|
2
|
+
// Also exports createNewProject() for reuse during onboarding (welcome channel creation).
|
|
2
3
|
import { ChannelType } from 'discord.js';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
5
7
|
import { getProjectsDir } from '../config.js';
|
|
6
8
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
9
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
8
10
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
9
|
-
import { createLogger } from '../logger.js';
|
|
10
|
-
const logger = createLogger(
|
|
11
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
|
+
const logger = createLogger(LogPrefix.CREATE_PROJECT);
|
|
13
|
+
/**
|
|
14
|
+
* Core project creation logic: creates directory, inits git, creates Discord channels.
|
|
15
|
+
* Reused by the slash command handler and by onboarding (welcome channel).
|
|
16
|
+
* Returns null if the project directory already exists.
|
|
17
|
+
*/
|
|
18
|
+
export async function createNewProject({ guild, projectName, appId, botName, }) {
|
|
19
|
+
const sanitizedName = projectName
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
22
|
+
.replace(/-+/g, '-')
|
|
23
|
+
.replace(/^-|-$/g, '')
|
|
24
|
+
.slice(0, 100);
|
|
25
|
+
if (!sanitizedName) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const projectsDir = getProjectsDir();
|
|
29
|
+
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
30
|
+
if (!fs.existsSync(projectsDir)) {
|
|
31
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
32
|
+
logger.log(`Created projects directory: ${projectsDir}`);
|
|
33
|
+
}
|
|
34
|
+
if (fs.existsSync(projectDirectory)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
38
|
+
logger.log(`Created project directory: ${projectDirectory}`);
|
|
39
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
40
|
+
logger.log(`Initialized git in: ${projectDirectory}`);
|
|
41
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
42
|
+
guild,
|
|
43
|
+
projectDirectory,
|
|
44
|
+
appId,
|
|
45
|
+
botName,
|
|
46
|
+
});
|
|
47
|
+
return { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName };
|
|
48
|
+
}
|
|
11
49
|
export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
12
50
|
await command.deferReply({ ephemeral: false });
|
|
13
51
|
const projectName = command.options.getString('name', true);
|
|
@@ -21,39 +59,31 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
|
21
59
|
await command.editReply('This command can only be used in a text channel');
|
|
22
60
|
return;
|
|
23
61
|
}
|
|
24
|
-
const sanitizedName = projectName
|
|
25
|
-
.toLowerCase()
|
|
26
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
27
|
-
.replace(/-+/g, '-')
|
|
28
|
-
.replace(/^-|-$/g, '')
|
|
29
|
-
.slice(0, 100);
|
|
30
|
-
if (!sanitizedName) {
|
|
31
|
-
await command.editReply('Invalid project name');
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const projectsDir = getProjectsDir();
|
|
35
|
-
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
36
62
|
try {
|
|
37
|
-
|
|
38
|
-
fs.mkdirSync(projectsDir, { recursive: true });
|
|
39
|
-
logger.log(`Created projects directory: ${projectsDir}`);
|
|
40
|
-
}
|
|
41
|
-
if (fs.existsSync(projectDirectory)) {
|
|
42
|
-
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
46
|
-
logger.log(`Created project directory: ${projectDirectory}`);
|
|
47
|
-
const { execSync } = await import('node:child_process');
|
|
48
|
-
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
49
|
-
logger.log(`Initialized git in: ${projectDirectory}`);
|
|
50
|
-
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
63
|
+
const result = await createNewProject({
|
|
51
64
|
guild,
|
|
52
|
-
|
|
65
|
+
projectName,
|
|
53
66
|
appId,
|
|
67
|
+
botName: command.client.user?.username,
|
|
54
68
|
});
|
|
69
|
+
if (!result) {
|
|
70
|
+
const sanitizedName = projectName
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
73
|
+
.replace(/-+/g, '-')
|
|
74
|
+
.replace(/^-|-$/g, '')
|
|
75
|
+
.slice(0, 100);
|
|
76
|
+
if (!sanitizedName) {
|
|
77
|
+
await command.editReply('Invalid project name');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const projectDirectory = path.join(getProjectsDir(), sanitizedName);
|
|
81
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result;
|
|
55
85
|
const textChannel = (await guild.channels.fetch(textChannelId));
|
|
56
|
-
await command.editReply(`ā
Created new project **${sanitizedName}**\nš Directory: \`${projectDirectory}\`\nš Text: <#${textChannelId}>\nš Voice: <#${voiceChannelId}>\
|
|
86
|
+
await command.editReply(`ā
Created new project **${sanitizedName}**\nš Directory: \`${projectDirectory}\`\nš Text: <#${textChannelId}>\nš Voice: <#${voiceChannelId}>\n_Starting session..._`);
|
|
57
87
|
const starterMessage = await textChannel.send({
|
|
58
88
|
content: `š **New project initialized**\nš \`${projectDirectory}\``,
|
|
59
89
|
flags: SILENT_MESSAGE_FLAGS,
|
package/dist/commands/fork.js
CHANGED
|
@@ -4,10 +4,10 @@ import { getDatabase } from '../database.js';
|
|
|
4
4
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
5
|
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
|
|
6
6
|
import { collectLastAssistantParts } from '../message-formatting.js';
|
|
7
|
-
import { createLogger } from '../logger.js';
|
|
7
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import * as errore from 'errore';
|
|
9
|
-
const sessionLogger = createLogger(
|
|
10
|
-
const forkLogger = createLogger(
|
|
9
|
+
const sessionLogger = createLogger(LogPrefix.SESSION);
|
|
10
|
+
const forkLogger = createLogger(LogPrefix.FORK);
|
|
11
11
|
export async function handleForkCommand(interaction) {
|
|
12
12
|
const channel = interaction.channel;
|
|
13
13
|
if (!channel) {
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// After merge, switches to detached HEAD at main so user can keep working.
|
|
4
4
|
import {} from 'discord.js';
|
|
5
5
|
import { getThreadWorktree } from '../database.js';
|
|
6
|
-
import { createLogger } from '../logger.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
import { execAsync } from '../worktree-utils.js';
|
|
8
|
-
const logger = createLogger(
|
|
8
|
+
const logger = createLogger(LogPrefix.WORKTREE);
|
|
9
9
|
/** Worktree thread title prefix - indicates unmerged worktree */
|
|
10
10
|
export const WORKTREE_PREFIX = '⬦ ';
|
|
11
11
|
/**
|
|
@@ -38,7 +38,8 @@ async function isDetachedHead(worktreeDir) {
|
|
|
38
38
|
await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
|
-
catch {
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.debug(`Failed to resolve HEAD for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
@@ -50,7 +51,8 @@ async function getCurrentBranch(worktreeDir) {
|
|
|
50
51
|
const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
|
|
51
52
|
return stdout.trim() || null;
|
|
52
53
|
}
|
|
53
|
-
catch {
|
|
54
|
+
catch (error) {
|
|
55
|
+
logger.debug(`Failed to get current branch for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
54
56
|
return null;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
@@ -89,7 +91,8 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
|
|
|
89
91
|
const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
|
|
90
92
|
defaultBranch = stdout.trim() || 'main';
|
|
91
93
|
}
|
|
92
|
-
catch {
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.warn(`Failed to detect default branch for ${mainRepoDir}, falling back to main:`, error instanceof Error ? error.message : String(error));
|
|
93
96
|
defaultBranch = 'main';
|
|
94
97
|
}
|
|
95
98
|
// 3. Determine if we're on a branch or detached HEAD
|
|
@@ -115,11 +118,17 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
|
|
|
115
118
|
}
|
|
116
119
|
catch (e) {
|
|
117
120
|
// If merge fails (conflicts), abort and report
|
|
118
|
-
await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => {
|
|
121
|
+
await execAsync(`git -C "${worktreeDir}" merge --abort`).catch((error) => {
|
|
122
|
+
logger.warn(`Failed to abort merge in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
123
|
+
});
|
|
119
124
|
// Clean up temp branch if we created one
|
|
120
125
|
if (tempBranch) {
|
|
121
|
-
await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => {
|
|
122
|
-
|
|
126
|
+
await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch((error) => {
|
|
127
|
+
logger.warn(`Failed to detach HEAD after merge conflict in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
128
|
+
});
|
|
129
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch((error) => {
|
|
130
|
+
logger.warn(`Failed to delete temp branch ${tempBranch} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
131
|
+
});
|
|
123
132
|
}
|
|
124
133
|
throw new Error(`Merge conflict - resolve manually in worktree then retry`);
|
|
125
134
|
}
|
|
@@ -133,10 +142,14 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
|
|
|
133
142
|
await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
|
|
134
143
|
// 7. Delete the merged branch (temp or original)
|
|
135
144
|
logger.log(`Deleting merged branch ${branchToMerge}`);
|
|
136
|
-
await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => {
|
|
145
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch((error) => {
|
|
146
|
+
logger.warn(`Failed to delete merged branch ${branchToMerge} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
147
|
+
});
|
|
137
148
|
// Also delete the original worktree branch if different from what we merged
|
|
138
149
|
if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
|
|
139
|
-
await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => {
|
|
150
|
+
await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch((error) => {
|
|
151
|
+
logger.warn(`Failed to delete worktree branch ${worktreeInfo.worktree_name} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
|
|
152
|
+
});
|
|
140
153
|
}
|
|
141
154
|
// 8. Remove worktree prefix from thread title (fire and forget with timeout)
|
|
142
155
|
void removeWorktreePrefixFromTitle(thread);
|