kimaki 0.13.1 → 0.15.0
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-commands/send.js +45 -15
- package/dist/cli-commands/session.js +53 -11
- package/dist/cli.js +18 -0
- package/dist/commands/abort.js +9 -6
- package/dist/commands/action-buttons.js +2 -0
- package/dist/commands/add-dir.js +1 -1
- package/dist/commands/ask-question.js +2 -0
- package/dist/commands/btw.js +27 -16
- package/dist/commands/compact.js +1 -1
- package/dist/commands/context-usage.js +16 -9
- package/dist/commands/file-upload.js +5 -1
- package/dist/commands/fork.js +9 -11
- package/dist/commands/login.js +2 -9
- package/dist/commands/model-variant.js +21 -16
- package/dist/commands/model.js +41 -13
- package/dist/commands/new-worktree.js +116 -129
- package/dist/commands/permissions.js +23 -9
- package/dist/commands/remove-project.js +5 -9
- package/dist/commands/undo-redo.js +17 -9
- package/dist/config.js +8 -0
- package/dist/context-awareness-plugin.js +135 -147
- package/dist/discord-bot.js +63 -19
- package/dist/discord-command-registration.js +8 -0
- package/dist/discord-utils.js +23 -36
- package/dist/errors.js +48 -30
- package/dist/external-opencode-sync.js +2 -4
- package/dist/forum-sync/markdown.js +1 -4
- package/dist/genai-worker.js +3 -5
- package/dist/hrana-server.js +5 -8
- package/dist/html-components.js +2 -4
- package/dist/ipc-polling.js +11 -18
- package/dist/markdown.js +95 -100
- package/dist/memory-overview-plugin.js +45 -50
- package/dist/message-formatting.js +4 -9
- package/dist/message-preprocessing.js +5 -6
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/opencode-interrupt-plugin.js +12 -6
- package/dist/opencode.js +64 -54
- package/dist/plugin-logger.js +37 -0
- package/dist/plugin-opencode-client.js +11 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +1 -0
- package/dist/queue-question-select-drain.e2e.test.js +2 -0
- package/dist/session-handler/agent-utils.js +3 -4
- package/dist/session-handler/global-event-listener.js +180 -0
- package/dist/session-handler/model-utils.js +7 -10
- package/dist/session-handler/opencode-session-event-log.js +5 -7
- package/dist/session-handler/thread-runtime-state.js +0 -1
- package/dist/session-handler/thread-session-runtime.js +188 -392
- package/dist/skill-filter.js +12 -0
- package/dist/skill-filter.test.js +22 -1
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +18 -20
- package/dist/system-message.js +13 -4
- package/dist/system-message.test.js +14 -3
- package/dist/task-runner.js +4 -8
- package/dist/task-schedule.js +21 -34
- package/dist/thread-message-queue.e2e.test.js +7 -2
- package/dist/voice-handler.js +10 -17
- package/dist/voice-message.e2e.test.js +1 -1
- package/dist/voice.js +8 -13
- package/dist/worktrees.js +40 -76
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +28 -4
- package/skills/new-skill/SKILL.md +7 -7
- package/skills/strada/SKILL.md +236 -0
- package/skills/termcast/SKILL.md +2 -0
- package/src/cli-commands/send.ts +49 -15
- package/src/cli-commands/session.ts +66 -7
- package/src/cli.ts +24 -0
- package/src/commands/abort.ts +9 -6
- package/src/commands/action-buttons.ts +6 -0
- package/src/commands/add-dir.ts +1 -1
- package/src/commands/ask-question.ts +6 -0
- package/src/commands/btw.ts +34 -24
- package/src/commands/compact.ts +1 -1
- package/src/commands/context-usage.ts +18 -9
- package/src/commands/file-upload.ts +9 -1
- package/src/commands/fork.ts +7 -6
- package/src/commands/login.ts +2 -8
- package/src/commands/model-variant.ts +21 -16
- package/src/commands/model.ts +52 -14
- package/src/commands/new-worktree.ts +65 -85
- package/src/commands/permissions.ts +23 -11
- package/src/commands/remove-project.ts +6 -9
- package/src/commands/undo-redo.ts +17 -9
- package/src/config.ts +9 -0
- package/src/context-awareness-plugin.ts +25 -38
- package/src/discord-bot.ts +82 -23
- package/src/discord-command-registration.ts +11 -0
- package/src/discord-utils.ts +16 -29
- package/src/errors.ts +49 -30
- package/src/external-opencode-sync.ts +2 -6
- package/src/forum-sync/markdown.ts +4 -4
- package/src/genai-worker.ts +4 -5
- package/src/hrana-server.ts +4 -4
- package/src/html-components.ts +2 -6
- package/src/ipc-polling.ts +9 -10
- package/src/markdown.ts +103 -110
- package/src/memory-overview-plugin.ts +9 -13
- package/src/message-formatting.ts +5 -9
- package/src/message-preprocessing.ts +5 -6
- package/src/openai-auth-plugin.ts +1 -0
- package/src/opencode-interrupt-plugin.ts +12 -6
- package/src/opencode.ts +49 -29
- package/src/plugin-logger.ts +55 -0
- package/src/plugin-opencode-client.ts +11 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +1 -0
- package/src/queue-advanced-e2e-setup.ts +3 -0
- package/src/queue-question-select-drain.e2e.test.ts +2 -0
- package/src/session-handler/agent-utils.ts +4 -4
- package/src/session-handler/global-event-listener.ts +226 -0
- package/src/session-handler/model-utils.ts +7 -12
- package/src/session-handler/opencode-session-event-log.ts +6 -7
- package/src/session-handler/thread-runtime-state.ts +0 -8
- package/src/session-handler/thread-session-runtime.ts +202 -434
- package/src/skill-filter.test.ts +28 -1
- package/src/skill-filter.ts +21 -0
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +6 -7
- package/src/system-message.test.ts +14 -3
- package/src/system-message.ts +17 -4
- package/src/task-runner.ts +4 -12
- package/src/task-schedule.ts +16 -24
- package/src/thread-message-queue.e2e.test.ts +8 -2
- package/src/voice-handler.ts +9 -16
- package/src/voice-message.e2e.test.ts +1 -1
- package/src/voice.ts +7 -12
- package/src/worktrees.ts +52 -106
|
@@ -138,12 +138,6 @@ cli
|
|
|
138
138
|
if (options.user) {
|
|
139
139
|
incompatibleFlags.push('--user');
|
|
140
140
|
}
|
|
141
|
-
if (!sendAt && options.agent) {
|
|
142
|
-
incompatibleFlags.push('--agent');
|
|
143
|
-
}
|
|
144
|
-
if (!sendAt && options.model) {
|
|
145
|
-
incompatibleFlags.push('--model');
|
|
146
|
-
}
|
|
147
141
|
if (incompatibleFlags.length > 0) {
|
|
148
142
|
cliLogger.error(`Incompatible options with --thread/--session: ${incompatibleFlags.join(', ')}`);
|
|
149
143
|
process.exit(EXIT_NO_RESTART);
|
|
@@ -313,6 +307,8 @@ cli
|
|
|
313
307
|
}
|
|
314
308
|
const threadPromptMarker = {
|
|
315
309
|
start: true,
|
|
310
|
+
...(options.agent && { agent: options.agent }),
|
|
311
|
+
...(options.model && { model: options.model }),
|
|
316
312
|
...(options.permission?.length ? { permissions: options.permission } : {}),
|
|
317
313
|
...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
|
|
318
314
|
};
|
|
@@ -334,8 +330,12 @@ cli
|
|
|
334
330
|
rest,
|
|
335
331
|
});
|
|
336
332
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
|
|
337
|
-
|
|
338
|
-
|
|
333
|
+
const existingSessionId = sessionId || await getThreadSession(targetThreadId);
|
|
334
|
+
const sessionLine = existingSessionId ? `Session: ${existingSessionId}\n` : '';
|
|
335
|
+
note(`Prompt sent to thread: ${threadData.name}\n${sessionLine}\nURL: ${threadUrl}`, '✅ Message Sent');
|
|
336
|
+
if (existingSessionId)
|
|
337
|
+
process.stdout.write(`Session: ${existingSessionId}\n`);
|
|
338
|
+
process.stdout.write(`${threadUrl}\n`);
|
|
339
339
|
if (options.wait) {
|
|
340
340
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
341
341
|
await waitAndOutputSession({
|
|
@@ -353,16 +353,18 @@ cli
|
|
|
353
353
|
// Get channel info to extract directory from topic
|
|
354
354
|
const channelData = (await rest.get(Routes.channel(channelId)));
|
|
355
355
|
const channelConfig = await getChannelDirectory(channelData.id);
|
|
356
|
-
if (!channelConfig) {
|
|
356
|
+
if (!channelConfig && !notifyOnly) {
|
|
357
357
|
cliLogger.log('Channel not configured');
|
|
358
358
|
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
359
359
|
}
|
|
360
|
-
const projectDirectory = channelConfig
|
|
360
|
+
const projectDirectory = channelConfig?.directory;
|
|
361
361
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
362
362
|
let resolvedCwd;
|
|
363
363
|
if (options.cwd) {
|
|
364
|
+
// projectDirectory is guaranteed here: --cwd is incompatible with --notify-only,
|
|
365
|
+
// and non-notify sends already require channelConfig above.
|
|
364
366
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
365
|
-
projectDirectory,
|
|
367
|
+
projectDirectory: projectDirectory,
|
|
366
368
|
candidatePath: options.cwd,
|
|
367
369
|
});
|
|
368
370
|
if (cwdResult instanceof Error) {
|
|
@@ -456,6 +458,14 @@ cli
|
|
|
456
458
|
embeds: autoStartEmbed,
|
|
457
459
|
rest,
|
|
458
460
|
});
|
|
461
|
+
// For notify-only on non-project channels, just post the message without
|
|
462
|
+
// creating a thread. There's no session to start, so a thread is unnecessary.
|
|
463
|
+
if (notifyOnly && !channelConfig) {
|
|
464
|
+
const messageUrl = `https://discord.com/channels/${channelData.guild_id}/${channelId}/${starterMessage.id}`;
|
|
465
|
+
note(`Channel: #${channelData.name}\n\nMessage sent.\n\nURL: ${messageUrl}`, '✅ Message Sent');
|
|
466
|
+
process.stdout.write(`${messageUrl}\n`);
|
|
467
|
+
process.exit(0);
|
|
468
|
+
}
|
|
459
469
|
cliLogger.log('Creating thread...');
|
|
460
470
|
const threadData = (await rest.post(Routes.threads(channelId, starterMessage.id), {
|
|
461
471
|
body: {
|
|
@@ -470,21 +480,41 @@ cli
|
|
|
470
480
|
await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id));
|
|
471
481
|
}
|
|
472
482
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
483
|
+
// Poll for session ID if the bot is expected to auto-start (not --notify-only).
|
|
484
|
+
// The bot picks up the thread and creates a session asynchronously;
|
|
485
|
+
// we wait briefly so the caller can reference the session immediately.
|
|
486
|
+
let newSessionId;
|
|
487
|
+
if (!notifyOnly) {
|
|
488
|
+
const { waitForSessionId } = await import('../wait-session.js');
|
|
489
|
+
newSessionId = await waitForSessionId({
|
|
490
|
+
threadId: threadData.id,
|
|
491
|
+
timeoutMs: 15_000,
|
|
492
|
+
}).catch((e) => {
|
|
493
|
+
cliLogger.warn(`Could not resolve session ID: ${e instanceof Error ? e.message : String(e)}`);
|
|
494
|
+
return undefined;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
473
497
|
const worktreeNote = worktreeName
|
|
474
498
|
? `\nWorktree: ${worktreeName} (will be created by bot)`
|
|
475
499
|
: resolvedCwd
|
|
476
500
|
? `\nWorking directory: ${resolvedCwd}`
|
|
477
501
|
: '';
|
|
502
|
+
const sessionLine = newSessionId ? `\nSession: ${newSessionId}` : '';
|
|
503
|
+
const directoryLine = projectDirectory ? `\nDirectory: ${projectDirectory}` : '';
|
|
478
504
|
const successMessage = notifyOnly
|
|
479
|
-
? `Thread: ${threadData.name}
|
|
480
|
-
: `Thread: ${threadData.name}
|
|
505
|
+
? `Thread: ${threadData.name}${directoryLine}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
506
|
+
: `Thread: ${threadData.name}${directoryLine}${worktreeNote}${sessionLine}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
|
|
481
507
|
note(successMessage, '✅ Thread Created');
|
|
482
|
-
|
|
508
|
+
if (newSessionId)
|
|
509
|
+
process.stdout.write(`Session: ${newSessionId}\n`);
|
|
510
|
+
process.stdout.write(`${threadUrl}\n`);
|
|
483
511
|
if (options.wait) {
|
|
512
|
+
// projectDirectory is guaranteed here: --wait is incompatible with --notify-only,
|
|
513
|
+
// and non-notify sends already require channelConfig above.
|
|
484
514
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
485
515
|
await waitAndOutputSession({
|
|
486
516
|
threadId: threadData.id,
|
|
487
|
-
projectDirectory,
|
|
517
|
+
projectDirectory: projectDirectory,
|
|
488
518
|
waitStartedAtMs,
|
|
489
519
|
});
|
|
490
520
|
}
|
|
@@ -166,9 +166,8 @@ cli
|
|
|
166
166
|
const dir = project.worktree;
|
|
167
167
|
cliLogger.log(`Trying project: ${dir}`);
|
|
168
168
|
const otherClient = await initializeOpencodeForDirectory(dir);
|
|
169
|
-
if (otherClient instanceof Error)
|
|
169
|
+
if (otherClient instanceof Error)
|
|
170
170
|
continue;
|
|
171
|
-
}
|
|
172
171
|
const otherMarkdown = new ShareMarkdown(otherClient());
|
|
173
172
|
const otherResult = await otherMarkdown.generate({
|
|
174
173
|
sessionID: sessionId,
|
|
@@ -387,15 +386,12 @@ cli
|
|
|
387
386
|
process.exit(EXIT_NO_RESTART);
|
|
388
387
|
}
|
|
389
388
|
const parsedRows = rows.flatMap((row) => {
|
|
390
|
-
const parsed = errore.try({
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
cause: error,
|
|
397
|
-
});
|
|
398
|
-
},
|
|
389
|
+
const parsed = errore.try(() => {
|
|
390
|
+
return JSON.parse(row.event_json);
|
|
391
|
+
}, (error) => {
|
|
392
|
+
return new Error('Failed to parse persisted event JSON', {
|
|
393
|
+
cause: error,
|
|
394
|
+
});
|
|
399
395
|
});
|
|
400
396
|
if (parsed instanceof Error) {
|
|
401
397
|
cliLogger.warn(`Skipping invalid persisted event row ${row.id}: ${parsed.message}`);
|
|
@@ -499,6 +495,52 @@ cli
|
|
|
499
495
|
process.exit(EXIT_NO_RESTART);
|
|
500
496
|
}
|
|
501
497
|
});
|
|
498
|
+
cli
|
|
499
|
+
.command('session abort <sessionId>', 'Abort a running session without archiving the thread')
|
|
500
|
+
.action(async (sessionId) => {
|
|
501
|
+
try {
|
|
502
|
+
await initDatabase();
|
|
503
|
+
const { token: botToken } = await resolveBotCredentials();
|
|
504
|
+
const rest = createDiscordRest(botToken);
|
|
505
|
+
// Try to resolve the project directory for the OpenCode abort call
|
|
506
|
+
const directory = await resolveSessionDirectoryFromDatabase({ sessionId });
|
|
507
|
+
if (directory instanceof Error) {
|
|
508
|
+
cliLogger.error(directory.message);
|
|
509
|
+
process.exit(EXIT_NO_RESTART);
|
|
510
|
+
}
|
|
511
|
+
const serverResult = await initializeOpencodeForDirectory(directory);
|
|
512
|
+
if (serverResult instanceof Error) {
|
|
513
|
+
cliLogger.error(`Failed to initialize OpenCode: ${serverResult.message}`);
|
|
514
|
+
process.exit(EXIT_NO_RESTART);
|
|
515
|
+
}
|
|
516
|
+
const client = serverResult();
|
|
517
|
+
// Don't pass directory — the server resolves sessions by ID regardless
|
|
518
|
+
// of the x-opencode-directory header, matching archiveThread's pattern.
|
|
519
|
+
// This avoids issues when --cwd was used (session directory != project directory).
|
|
520
|
+
const abortResult = await client.session.abort({
|
|
521
|
+
sessionID: sessionId,
|
|
522
|
+
}).catch((e) => new Error('Failed to abort session', { cause: e }));
|
|
523
|
+
if (abortResult instanceof Error) {
|
|
524
|
+
cliLogger.error(abortResult.message);
|
|
525
|
+
process.exit(EXIT_NO_RESTART);
|
|
526
|
+
}
|
|
527
|
+
// Post a message in the Discord thread so it's clear why the session stopped
|
|
528
|
+
const threadId = await getThreadIdBySessionId(sessionId);
|
|
529
|
+
if (threadId) {
|
|
530
|
+
await rest.post(Routes.channelMessages(threadId), {
|
|
531
|
+
body: { content: 'Session aborted via CLI' },
|
|
532
|
+
}).catch((e) => {
|
|
533
|
+
cliLogger.warn(`Could not post abort message to thread: ${e instanceof Error ? e.message : String(e)}`);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
note(`Aborted session: ${sessionId}${threadId ? `\nThread ID: ${threadId}` : ''}`, '✅ Aborted');
|
|
537
|
+
process.exit(0);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
|
|
541
|
+
process.exit(EXIT_NO_RESTART);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
502
544
|
cli
|
|
503
545
|
.command('session discord-url <sessionId>', 'Print the Discord thread URL for a session')
|
|
504
546
|
.option('--json', 'Output as JSON')
|
package/dist/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ cli
|
|
|
40
40
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
41
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
42
42
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
43
|
+
.option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
|
|
43
44
|
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
44
45
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
45
46
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
@@ -134,6 +135,19 @@ cli
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
}
|
|
138
|
+
// --permission-timeout-minutes validation
|
|
139
|
+
// Node setTimeout max is 2_147_483_647ms; larger values fire immediately.
|
|
140
|
+
const MAX_TIMEOUT_MINUTES = Math.floor(2_147_483_647 / 60_000);
|
|
141
|
+
const permissionTimeoutMs = (() => {
|
|
142
|
+
if (!options.permissionTimeoutMinutes)
|
|
143
|
+
return undefined;
|
|
144
|
+
const parsed = Number(options.permissionTimeoutMinutes);
|
|
145
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TIMEOUT_MINUTES) {
|
|
146
|
+
cliLogger.error(`Invalid permission timeout: ${options.permissionTimeoutMinutes}. Must be a positive whole number of minutes (max ${MAX_TIMEOUT_MINUTES}).`);
|
|
147
|
+
process.exit(EXIT_NO_RESTART);
|
|
148
|
+
}
|
|
149
|
+
return parsed * 60_000;
|
|
150
|
+
})();
|
|
137
151
|
store.setState({
|
|
138
152
|
...(defaultVerbosity && {
|
|
139
153
|
defaultVerbosity,
|
|
@@ -141,6 +155,7 @@ cli
|
|
|
141
155
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
142
156
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
143
157
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
158
|
+
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
144
159
|
...(options.disableSync && { syncEnabled: false }),
|
|
145
160
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
146
161
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
@@ -155,6 +170,9 @@ cli
|
|
|
155
170
|
if (options.allowAllUsers) {
|
|
156
171
|
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
157
172
|
}
|
|
173
|
+
if (permissionTimeoutMs !== undefined) {
|
|
174
|
+
cliLogger.log(`Permission timeout set to ${options.permissionTimeoutMinutes} minutes`);
|
|
175
|
+
}
|
|
158
176
|
if (options.verbosity) {
|
|
159
177
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
160
178
|
}
|
package/dist/commands/abort.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// /abort command - Abort the current OpenCode request in this thread.
|
|
2
2
|
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import { getThreadSession } from '../database.js';
|
|
4
|
-
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
4
|
+
import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
5
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
6
|
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
@@ -35,7 +35,7 @@ export async function handleAbortCommand({ command, }) {
|
|
|
35
35
|
await command.editReply('Could not determine project directory for this channel');
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
const { projectDirectory } = resolved;
|
|
38
|
+
const { projectDirectory, workingDirectory } = resolved;
|
|
39
39
|
const sessionId = await getThreadSession(channel.id);
|
|
40
40
|
if (!sessionId) {
|
|
41
41
|
await command.editReply('No active session in this thread');
|
|
@@ -48,13 +48,16 @@ export async function handleAbortCommand({ command, }) {
|
|
|
48
48
|
}
|
|
49
49
|
else {
|
|
50
50
|
// No runtime but session exists — fall back to direct API abort
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
53
|
-
await command.editReply(`Failed to abort: ${
|
|
51
|
+
const serverResult = await initializeOpencodeForDirectory(projectDirectory);
|
|
52
|
+
if (serverResult instanceof Error) {
|
|
53
|
+
await command.editReply(`Failed to abort: ${serverResult.message}`);
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
try {
|
|
57
|
-
|
|
57
|
+
const client = getOpencodeClient(workingDirectory);
|
|
58
|
+
if (client) {
|
|
59
|
+
await client.session.abort({ sessionID: sessionId, directory: workingDirectory });
|
|
60
|
+
}
|
|
58
61
|
}
|
|
59
62
|
catch (error) {
|
|
60
63
|
logger.error('[ABORT] API abort failed:', error);
|
|
@@ -212,7 +212,9 @@ export async function handleActionButton(interaction) {
|
|
|
212
212
|
content: `**Action Required**\n_Selected: ${button.label}_`,
|
|
213
213
|
components: [],
|
|
214
214
|
});
|
|
215
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
215
216
|
const prompt = `User clicked: ${button.label}`;
|
|
217
|
+
await sendThreadMessage(thread, `» **${username}:** ${button.label}`);
|
|
216
218
|
try {
|
|
217
219
|
await sendClickedActionToModel({
|
|
218
220
|
interaction,
|
package/dist/commands/add-dir.js
CHANGED
|
@@ -132,7 +132,7 @@ export async function handleAddDirCommand({ command, }) {
|
|
|
132
132
|
await command.editReply(`Failed to update session permissions: ${getClient.message}`);
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
|
-
const client = getOpencodeClient(resolvedDirectories.
|
|
135
|
+
const client = getOpencodeClient(resolvedDirectories.workingDirectory);
|
|
136
136
|
if (!client) {
|
|
137
137
|
await command.editReply('Failed to get OpenCode client');
|
|
138
138
|
return;
|
|
@@ -183,6 +183,8 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
183
183
|
content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
|
|
184
184
|
components: [], // Remove the dropdown
|
|
185
185
|
});
|
|
186
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
187
|
+
await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
|
|
186
188
|
// Check if all questions are answered
|
|
187
189
|
if (context.answeredCount >= context.totalQuestions) {
|
|
188
190
|
// All questions answered - send result back to session
|
package/dist/commands/btw.js
CHANGED
|
@@ -10,41 +10,52 @@ import { createLogger, LogPrefix } from '../logger.js';
|
|
|
10
10
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
11
11
|
const logger = createLogger(LogPrefix.FORK);
|
|
12
12
|
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
|
|
13
|
-
|
|
13
|
+
// Parallelize: session lookup + opencode init + parent channel resolve are independent
|
|
14
|
+
const [sessionId, getClientResult, textChannel] = await Promise.all([
|
|
15
|
+
getThreadSession(sourceThread.id),
|
|
16
|
+
initializeOpencodeForDirectory(projectDirectory),
|
|
17
|
+
resolveTextChannel(sourceThread),
|
|
18
|
+
]);
|
|
14
19
|
if (!sessionId) {
|
|
15
20
|
return new Error('No active session in this thread');
|
|
16
21
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
cause: getClient,
|
|
22
|
+
if (getClientResult instanceof Error) {
|
|
23
|
+
return new Error(`Failed to fork session: ${getClientResult.message}`, {
|
|
24
|
+
cause: getClientResult,
|
|
21
25
|
});
|
|
22
26
|
}
|
|
23
|
-
const forkResponse = await getClient().session.fork({
|
|
24
|
-
sessionID: sessionId,
|
|
25
|
-
});
|
|
26
|
-
if (!forkResponse.data) {
|
|
27
|
-
return new Error('Failed to fork session');
|
|
28
|
-
}
|
|
29
|
-
const textChannel = await resolveTextChannel(sourceThread);
|
|
30
27
|
if (!textChannel) {
|
|
31
28
|
return new Error('Could not resolve parent text channel');
|
|
32
29
|
}
|
|
30
|
+
// Fork must succeed before creating the Discord thread to avoid orphan threads
|
|
31
|
+
const forkResponse = await getClientResult().session.fork({ sessionID: sessionId });
|
|
32
|
+
if (!forkResponse.data) {
|
|
33
|
+
return new Error('Failed to fork session');
|
|
34
|
+
}
|
|
33
35
|
const forkedSession = forkResponse.data;
|
|
34
36
|
const thread = await textChannel.threads.create({
|
|
35
37
|
name: `btw: ${prompt}`.slice(0, 100),
|
|
36
38
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
37
39
|
reason: `btw fork from session ${sessionId}`,
|
|
38
40
|
});
|
|
41
|
+
// DB mapping must complete before user-visible actions so the thread is routable
|
|
39
42
|
await setThreadSession(thread.id, forkedSession.id);
|
|
40
|
-
|
|
41
|
-
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`);
|
|
43
|
+
// Parallelize: member add and status message are independent best-effort actions
|
|
42
44
|
const sourceThreadLink = `<#${sourceThread.id}>`;
|
|
43
|
-
await
|
|
45
|
+
await Promise.all([
|
|
46
|
+
thread.members.add(userId).catch(() => { }),
|
|
47
|
+
sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
|
|
48
|
+
]);
|
|
49
|
+
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
|
|
44
50
|
const wrappedPrompt = [
|
|
45
51
|
`The user asked a side question while you were working on another task.`,
|
|
46
52
|
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
47
|
-
`Do NOT continue, resume, or reference the previous task. Only answer the question below
|
|
53
|
+
`Do NOT continue, resume, or reference the previous task. Only answer the question below.`,
|
|
54
|
+
``,
|
|
55
|
+
`Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
|
|
56
|
+
`If the user asks you to send a message or follow-up to the parent session, use:`,
|
|
57
|
+
` kimaki send --session ${sessionId} --prompt 'your message here'`,
|
|
58
|
+
``,
|
|
48
59
|
prompt,
|
|
49
60
|
].join('\n');
|
|
50
61
|
const runtime = getOrCreateRuntime({
|
package/dist/commands/compact.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function handleCompactCommand({ command, }) {
|
|
|
54
54
|
});
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
const client = getOpencodeClient(
|
|
57
|
+
const client = getOpencodeClient(workingDirectory);
|
|
58
58
|
if (!client) {
|
|
59
59
|
await command.reply({
|
|
60
60
|
content: 'Failed to get OpenCode client',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// /context-usage command - Show token usage and context window percentage for the current session.
|
|
2
2
|
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
|
+
import { OpenCodeSdkError } from '../errors.js';
|
|
3
4
|
import { getThreadSession } from '../database.js';
|
|
4
|
-
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
6
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
-
import * as errore from 'errore';
|
|
8
8
|
const logger = createLogger(LogPrefix.SESSION);
|
|
9
9
|
function getTokenTotal({ input, output, reasoning, cache, }) {
|
|
10
10
|
return input + output + reasoning + cache.read + cache.write;
|
|
@@ -49,17 +49,25 @@ export async function handleContextUsageCommand({ command, }) {
|
|
|
49
49
|
});
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
52
|
+
const serverResult = await initializeOpencodeForDirectory(projectDirectory);
|
|
53
|
+
if (serverResult instanceof Error) {
|
|
54
54
|
await command.reply({
|
|
55
|
-
content: `Failed to get context usage: ${
|
|
55
|
+
content: `Failed to get context usage: ${serverResult.message}`,
|
|
56
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const client = getOpencodeClient(workingDirectory);
|
|
61
|
+
if (!client) {
|
|
62
|
+
await command.reply({
|
|
63
|
+
content: 'Failed to get OpenCode client',
|
|
56
64
|
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
57
65
|
});
|
|
58
66
|
return;
|
|
59
67
|
}
|
|
60
68
|
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
61
69
|
try {
|
|
62
|
-
const messagesResponse = await
|
|
70
|
+
const messagesResponse = await client.session.messages({
|
|
63
71
|
sessionID: sessionId,
|
|
64
72
|
directory: workingDirectory,
|
|
65
73
|
});
|
|
@@ -98,9 +106,8 @@ export async function handleContextUsageCommand({ command, }) {
|
|
|
98
106
|
}, 0);
|
|
99
107
|
// Fetch model context limit from provider API
|
|
100
108
|
let contextLimit;
|
|
101
|
-
const providersResult = await
|
|
102
|
-
|
|
103
|
-
});
|
|
109
|
+
const providersResult = await client.provider.list({ directory: workingDirectory })
|
|
110
|
+
.catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }));
|
|
104
111
|
if (providersResult instanceof Error) {
|
|
105
112
|
logger.error('[CONTEXT-USAGE] Failed to fetch provider info:', providersResult);
|
|
106
113
|
}
|
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
13
|
import { notifyError } from '../sentry.js';
|
|
14
|
-
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
14
|
+
import { NOTIFY_MESSAGE_FLAGS, sendThreadMessage } from '../discord-utils.js';
|
|
15
15
|
const logger = createLogger(LogPrefix.FILE_UPLOAD);
|
|
16
16
|
// 5 minute TTL for pending contexts - if user doesn't click within this time,
|
|
17
17
|
// clean up the context and resolve with empty array to unblock the plugin tool
|
|
@@ -219,6 +219,10 @@ export async function handleFileUploadModalSubmit(interaction) {
|
|
|
219
219
|
return `Upload failed: ${errors.join('; ')}`;
|
|
220
220
|
})();
|
|
221
221
|
await interaction.editReply({ content: summary });
|
|
222
|
+
if (downloadedPaths.length > 0) {
|
|
223
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
224
|
+
await sendThreadMessage(context.thread, `» **${username}:** Uploaded ${fileNames.join(', ')}`);
|
|
225
|
+
}
|
|
222
226
|
resolveContext(context, downloadedPaths);
|
|
223
227
|
logger.log(`File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`);
|
|
224
228
|
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -27,15 +27,12 @@ function getThreadChannel(channel) {
|
|
|
27
27
|
}
|
|
28
28
|
function parsePersistedEventRows({ rows, }) {
|
|
29
29
|
return rows.flatMap((row) => {
|
|
30
|
-
const parsed = errore.try({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
cause: error,
|
|
37
|
-
});
|
|
38
|
-
},
|
|
30
|
+
const parsed = errore.try(() => {
|
|
31
|
+
return JSON.parse(row.event_json);
|
|
32
|
+
}, (error) => {
|
|
33
|
+
return new Error('Failed to parse persisted event JSON', {
|
|
34
|
+
cause: error,
|
|
35
|
+
});
|
|
39
36
|
});
|
|
40
37
|
if (parsed instanceof Error) {
|
|
41
38
|
forkLogger.warn(`[fork] Skipping invalid persisted event row ${row.id}: ${parsed.message}`);
|
|
@@ -230,8 +227,9 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
230
227
|
await setThreadSession(thread.id, forkedSession.id);
|
|
231
228
|
// Add user to thread so it appears in their sidebar
|
|
232
229
|
await thread.members.add(interaction.user.id);
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
const sourceThreadLink = `<#${threadChannel.id}>`;
|
|
231
|
+
sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id} from source thread ${threadChannel.id} (session ${sessionId})`);
|
|
232
|
+
await sendThreadMessage(thread, `**Forked session created!**\nFrom: ${sourceThreadLink} (\`${sessionId}\`)\nNew session: \`${forkedSession.id}\``);
|
|
235
233
|
// Fetch and display the last assistant messages from the forked session
|
|
236
234
|
const messagesResponse = await getClient().session.messages({
|
|
237
235
|
sessionID: forkedSession.id,
|
package/dist/commands/login.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// login_text:<hash> — text prompt modal submission
|
|
11
11
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ModalSubmitInteraction, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, } from 'discord.js';
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
|
-
import { initializeOpencodeForDirectory, getOpencodeServerPort, } from '../opencode.js';
|
|
13
|
+
import { initializeOpencodeForDirectory, getOpencodeServerPort, getOpencodeServerAuthHeaders, } from '../opencode.js';
|
|
14
14
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
15
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
16
16
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
@@ -784,18 +784,11 @@ async function startOAuthFlow(interaction, ctx, hash) {
|
|
|
784
784
|
const hasInputs = Object.keys(ctx.inputs).length > 0;
|
|
785
785
|
const authorizeUrl = new URL(`/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`, `http://127.0.0.1:${port}`);
|
|
786
786
|
authorizeUrl.searchParams.set('directory', ctx.dir);
|
|
787
|
-
// Include basic auth if OPENCODE_SERVER_PASSWORD is set,
|
|
788
|
-
// matching the opencode server's optional basicAuth middleware.
|
|
789
787
|
const fetchHeaders = {
|
|
790
788
|
'Content-Type': 'application/json',
|
|
791
789
|
'x-opencode-directory': ctx.dir,
|
|
790
|
+
...getOpencodeServerAuthHeaders(),
|
|
792
791
|
};
|
|
793
|
-
const serverPassword = process.env.OPENCODE_SERVER_PASSWORD;
|
|
794
|
-
if (serverPassword) {
|
|
795
|
-
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
|
|
796
|
-
fetchHeaders['Authorization'] =
|
|
797
|
-
`Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`;
|
|
798
|
-
}
|
|
799
792
|
const authorizeRes = await fetch(authorizeUrl, {
|
|
800
793
|
method: 'POST',
|
|
801
794
|
headers: fetchHeaders,
|
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
8
8
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import { setChannelModel,
|
|
10
|
+
import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
11
11
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
12
12
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
13
|
-
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from './model.js';
|
|
14
|
-
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
|
+
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, applyToCurrentSession, } from './model.js';
|
|
15
14
|
import { getThinkingValuesForModel } from '../thinking-utils.js';
|
|
16
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
17
16
|
const logger = createLogger(LogPrefix.MODEL);
|
|
@@ -303,22 +302,14 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
303
302
|
});
|
|
304
303
|
return;
|
|
305
304
|
}
|
|
306
|
-
await
|
|
305
|
+
const retried = await applyToCurrentSession({
|
|
307
306
|
sessionId: context.sessionId,
|
|
307
|
+
thread: context.thread,
|
|
308
308
|
modelId,
|
|
309
309
|
variant,
|
|
310
310
|
});
|
|
311
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
311
312
|
logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
|
|
312
|
-
let retried = false;
|
|
313
|
-
if (context.thread) {
|
|
314
|
-
const runtime = getRuntime(context.thread.id);
|
|
315
|
-
if (runtime) {
|
|
316
|
-
retried = await runtime.retryLastUserPrompt();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const retryNote = retried
|
|
320
|
-
? '\n_Restarting current request with new variant..._'
|
|
321
|
-
: '';
|
|
322
313
|
await interaction.editReply({
|
|
323
314
|
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
|
|
324
315
|
flags: MessageFlags.SuppressEmbeds,
|
|
@@ -332,9 +323,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
332
323
|
modelId,
|
|
333
324
|
variant,
|
|
334
325
|
});
|
|
326
|
+
const retried = await applyToCurrentSession({
|
|
327
|
+
sessionId: context.sessionId,
|
|
328
|
+
thread: context.thread,
|
|
329
|
+
modelId,
|
|
330
|
+
variant,
|
|
331
|
+
});
|
|
332
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
335
333
|
logger.log(`Set global variant ${variant ?? 'none'} for app ${context.appId} and channel ${context.channelId} (model ${modelId})`);
|
|
336
334
|
await interaction.editReply({
|
|
337
|
-
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${agentTip}`,
|
|
335
|
+
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${retryNote}${agentTip}`,
|
|
338
336
|
flags: MessageFlags.SuppressEmbeds,
|
|
339
337
|
components: [],
|
|
340
338
|
});
|
|
@@ -346,9 +344,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
346
344
|
modelId,
|
|
347
345
|
variant,
|
|
348
346
|
});
|
|
347
|
+
const retried = await applyToCurrentSession({
|
|
348
|
+
sessionId: context.sessionId,
|
|
349
|
+
thread: context.thread,
|
|
350
|
+
modelId,
|
|
351
|
+
variant,
|
|
352
|
+
});
|
|
353
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
349
354
|
logger.log(`Set channel variant ${variant ?? 'none'} for channel ${context.channelId} (model ${modelId})`);
|
|
350
355
|
await interaction.editReply({
|
|
351
|
-
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${agentTip}`,
|
|
356
|
+
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${retryNote}${agentTip}`,
|
|
352
357
|
flags: MessageFlags.SuppressEmbeds,
|
|
353
358
|
components: [],
|
|
354
359
|
});
|