kimaki 0.16.0 → 0.17.1
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/agent-model.e2e.test.js +74 -1
- package/dist/channel-management.js +6 -8
- package/dist/cli-runner.js +17 -4
- package/dist/commands/btw.js +12 -3
- package/dist/commands/last-sessions.js +3 -2
- package/dist/commands/merge-worktree.js +6 -1
- package/dist/commands/model.js +27 -10
- package/dist/commands/new-worktree.js +9 -0
- package/dist/commands/worktrees.js +19 -3
- package/dist/db.test.js +27 -1
- package/dist/format-tables.js +138 -1
- package/dist/format-tables.test.js +224 -1
- package/dist/interaction-handler.js +26 -7
- package/dist/opencode.js +20 -0
- package/dist/voice-handler.js +6 -0
- package/package.json +5 -5
- package/src/agent-model.e2e.test.ts +90 -2
- package/src/channel-management.ts +6 -8
- package/src/cli-runner.ts +19 -6
- package/src/commands/btw.ts +13 -3
- package/src/commands/last-sessions.ts +4 -2
- package/src/commands/merge-worktree.ts +7 -0
- package/src/commands/model.ts +44 -10
- package/src/commands/new-worktree.ts +10 -0
- package/src/commands/worktrees.ts +20 -3
- package/src/db.test.ts +34 -0
- package/src/format-tables.test.ts +246 -0
- package/src/format-tables.ts +176 -1
- package/src/interaction-handler.ts +25 -9
- package/src/opencode.ts +20 -0
- package/src/voice-handler.ts +10 -0
|
@@ -19,7 +19,7 @@ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provid
|
|
|
19
19
|
import { setDataDir } from './config.js';
|
|
20
20
|
import { store } from './store.js';
|
|
21
21
|
import { startDiscordBot } from './discord-bot.js';
|
|
22
|
-
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionAgent, getChannelAgent, } from './database.js';
|
|
22
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, setSessionModel, } from './database.js';
|
|
23
23
|
import { getDb } from './db.js';
|
|
24
24
|
import * as orm from 'drizzle-orm';
|
|
25
25
|
import * as schema from './schema.js';
|
|
@@ -213,6 +213,9 @@ describe('agent model resolution', () => {
|
|
|
213
213
|
});
|
|
214
214
|
// Add extra models to the provider so opencode accepts them
|
|
215
215
|
const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
|
|
216
|
+
if (!providerConfig) {
|
|
217
|
+
throw new Error(`Missing deterministic provider config for ${PROVIDER_NAME}`);
|
|
218
|
+
}
|
|
216
219
|
providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
|
|
217
220
|
providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
|
|
218
221
|
providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
|
|
@@ -532,6 +535,76 @@ describe('agent model resolution', () => {
|
|
|
532
535
|
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
533
536
|
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
534
537
|
}, 15_000);
|
|
538
|
+
test('/btw fork keeps source session model when channel model differs', async () => {
|
|
539
|
+
const db = await getDb();
|
|
540
|
+
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
541
|
+
await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
|
|
542
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
543
|
+
content: 'Reply with exactly: btw-source-msg',
|
|
544
|
+
});
|
|
545
|
+
const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
546
|
+
timeout: 4_000,
|
|
547
|
+
predicate: (t) => {
|
|
548
|
+
return t.name === 'Reply with exactly: btw-source-msg';
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
await waitForFooterMessage({
|
|
552
|
+
discord,
|
|
553
|
+
threadId: sourceThread.id,
|
|
554
|
+
timeout: 6_000,
|
|
555
|
+
afterMessageIncludes: 'ok',
|
|
556
|
+
afterAuthorId: discord.botUserId,
|
|
557
|
+
});
|
|
558
|
+
const sourceSessionId = await getThreadSession(sourceThread.id);
|
|
559
|
+
expect(sourceSessionId).toBeDefined();
|
|
560
|
+
if (!sourceSessionId)
|
|
561
|
+
throw new Error('Expected source session');
|
|
562
|
+
await setSessionModel({
|
|
563
|
+
sessionId: sourceSessionId,
|
|
564
|
+
modelId: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
|
|
565
|
+
});
|
|
566
|
+
await setChannelModel({
|
|
567
|
+
channelId: TEXT_CHANNEL_ID,
|
|
568
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
569
|
+
});
|
|
570
|
+
await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
|
|
571
|
+
content: 'Reply with exactly: btw-model-check. btw',
|
|
572
|
+
});
|
|
573
|
+
const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
574
|
+
timeout: 4_000,
|
|
575
|
+
predicate: (t) => {
|
|
576
|
+
return t.name === 'btw: Reply with exactly: btw-model-check';
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
await waitForFooterMessage({
|
|
580
|
+
discord,
|
|
581
|
+
threadId: forkedThread.id,
|
|
582
|
+
timeout: 6_000,
|
|
583
|
+
afterMessageIncludes: 'ok',
|
|
584
|
+
afterAuthorId: discord.botUserId,
|
|
585
|
+
});
|
|
586
|
+
const forkedSessionId = await getThreadSession(forkedThread.id);
|
|
587
|
+
expect(forkedSessionId).toBeDefined();
|
|
588
|
+
const forkedSessionModel = forkedSessionId
|
|
589
|
+
? await getSessionModel(forkedSessionId)
|
|
590
|
+
: undefined;
|
|
591
|
+
const forkedThreadText = (await discord.thread(forkedThread.id).text())
|
|
592
|
+
.replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>');
|
|
593
|
+
expect(forkedThreadText).toMatchInlineSnapshot(`
|
|
594
|
+
"--- from: assistant (TestBot)
|
|
595
|
+
Reusing context from <#SOURCE_THREAD> to answer prompt...
|
|
596
|
+
Reply with exactly: btw-model-check
|
|
597
|
+
⬥ ok
|
|
598
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
|
|
599
|
+
`);
|
|
600
|
+
expect(forkedSessionModel).toMatchInlineSnapshot(`
|
|
601
|
+
{
|
|
602
|
+
"modelId": "deterministic-provider/plan-model-v2",
|
|
603
|
+
"variant": null,
|
|
604
|
+
}
|
|
605
|
+
`);
|
|
606
|
+
expect(forkedSessionModel?.modelId).not.toBe(`${PROVIDER_NAME}/${CHANNEL_MODEL}`);
|
|
607
|
+
}, 20_000);
|
|
535
608
|
test('changing channel agent via /plan-agent does not affect existing thread model', async () => {
|
|
536
609
|
// 1. Set channel agent to test-agent (uses AGENT_MODEL)
|
|
537
610
|
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
@@ -153,7 +153,11 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
153
153
|
logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
|
-
// 2. Fallback: detect existing channel by name+category
|
|
156
|
+
// 2. Fallback: detect existing channel by name+category.
|
|
157
|
+
// If a "kimaki" channel already exists in the guild but is NOT in our local
|
|
158
|
+
// DB, it was likely created by another kimaki instance (different machine).
|
|
159
|
+
// Do NOT adopt it — just skip channel creation entirely to avoid both
|
|
160
|
+
// instances fighting over the same channel.
|
|
157
161
|
const kimakiCategory = await ensureKimakiCategory(guild, botName);
|
|
158
162
|
const existingByName = guild.channels.cache.find((ch) => {
|
|
159
163
|
if (ch.type !== ChannelType.GuildText) {
|
|
@@ -165,13 +169,7 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
165
169
|
return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
|
|
166
170
|
});
|
|
167
171
|
if (existingByName) {
|
|
168
|
-
logger.log(`Found existing default kimaki channel by name: ${existingByName.id},
|
|
169
|
-
await setChannelDirectory({
|
|
170
|
-
channelId: existingByName.id,
|
|
171
|
-
directory: projectDirectory,
|
|
172
|
-
channelType: 'text',
|
|
173
|
-
skipIfExists: true,
|
|
174
|
-
});
|
|
172
|
+
logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`);
|
|
175
173
|
return null;
|
|
176
174
|
}
|
|
177
175
|
// Git init — gracefully skip if git is not installed
|
package/dist/cli-runner.js
CHANGED
|
@@ -848,6 +848,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
848
848
|
pollUrl.searchParams.set('secret', clientSecret);
|
|
849
849
|
let guildId;
|
|
850
850
|
let installerDiscordUserId;
|
|
851
|
+
let onboardingError;
|
|
851
852
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
852
853
|
await new Promise((resolve) => {
|
|
853
854
|
setTimeout(resolve, 3000);
|
|
@@ -860,7 +861,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
860
861
|
else if (attempt === 45) {
|
|
861
862
|
s?.message(`Still waiting... If you don't see any servers, create one first (+ button in Discord sidebar), then reopen the URL above`);
|
|
862
863
|
}
|
|
863
|
-
else if (attempt ===
|
|
864
|
+
else if (attempt === 75) {
|
|
864
865
|
s?.message(`Still waiting... Reopen the install URL if you closed it:\n${oauthUrl}`);
|
|
865
866
|
}
|
|
866
867
|
}
|
|
@@ -874,19 +875,31 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
874
875
|
break;
|
|
875
876
|
}
|
|
876
877
|
}
|
|
878
|
+
else if (resp.status === 404) {
|
|
879
|
+
// Check if the server returned a specific onboarding error
|
|
880
|
+
// (e.g. guild_id missing from Discord callback)
|
|
881
|
+
const data = (await resp.json().catch(() => null));
|
|
882
|
+
if (data?.onboarding_error && data.error) {
|
|
883
|
+
onboardingError = data.error;
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
877
887
|
}
|
|
878
888
|
catch {
|
|
879
889
|
// Network error, retry
|
|
880
890
|
}
|
|
881
891
|
}
|
|
882
892
|
if (!guildId) {
|
|
893
|
+
const errorMsg = onboardingError
|
|
894
|
+
? `Authorization failed: ${onboardingError}`
|
|
895
|
+
: 'Bot authorization timed out after 5 minutes. Please try again.';
|
|
883
896
|
if (isInteractive) {
|
|
884
|
-
s?.stop('Authorization timed out');
|
|
897
|
+
s?.stop(onboardingError ? 'Authorization failed' : 'Authorization timed out');
|
|
885
898
|
}
|
|
886
899
|
else {
|
|
887
|
-
emitJsonEvent({ type: 'error', message:
|
|
900
|
+
emitJsonEvent({ type: 'error', message: errorMsg });
|
|
888
901
|
}
|
|
889
|
-
cliLogger.error(
|
|
902
|
+
cliLogger.error(errorMsg);
|
|
890
903
|
process.exit(EXIT_NO_RESTART);
|
|
891
904
|
}
|
|
892
905
|
if (isInteractive) {
|
package/dist/commands/btw.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from
|
|
|
8
8
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
11
|
+
import { copyCurrentSessionModel } from './model.js';
|
|
11
12
|
const logger = createLogger(LogPrefix.FORK);
|
|
12
13
|
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
|
|
13
14
|
// Parallelize: session lookup + opencode init + parent channel resolve are independent
|
|
@@ -33,6 +34,15 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
33
34
|
return new Error('Failed to fork session');
|
|
34
35
|
}
|
|
35
36
|
const forkedSession = forkResponse.data;
|
|
37
|
+
const channelId = sourceThread.parentId || sourceThread.id;
|
|
38
|
+
await copyCurrentSessionModel({
|
|
39
|
+
sourceSessionId: sessionId,
|
|
40
|
+
targetSessionId: forkedSession.id,
|
|
41
|
+
channelId,
|
|
42
|
+
appId,
|
|
43
|
+
getClient: getClientResult,
|
|
44
|
+
directory: projectDirectory,
|
|
45
|
+
});
|
|
36
46
|
const thread = await textChannel.threads.create({
|
|
37
47
|
name: `btw: ${prompt}`.slice(0, 100),
|
|
38
48
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
@@ -53,8 +63,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
53
63
|
`Do NOT continue, resume, or reference the previous task. Only answer the question below.`,
|
|
54
64
|
``,
|
|
55
65
|
`Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
|
|
56
|
-
`
|
|
57
|
-
` kimaki send --session ${sessionId} --prompt 'your message here'`,
|
|
66
|
+
`Do NOT send messages to the parent session unless the user explicitly asks you to.`,
|
|
58
67
|
``,
|
|
59
68
|
prompt,
|
|
60
69
|
].join('\n');
|
|
@@ -63,7 +72,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
63
72
|
thread,
|
|
64
73
|
projectDirectory,
|
|
65
74
|
sdkDirectory: projectDirectory,
|
|
66
|
-
channelId
|
|
75
|
+
channelId,
|
|
67
76
|
appId,
|
|
68
77
|
});
|
|
69
78
|
await runtime.enqueueIncoming({
|
|
@@ -5,7 +5,7 @@ import { ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'disco
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { getDb } from '../db.js';
|
|
7
7
|
import { getChannelDirectory } from '../database.js';
|
|
8
|
-
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
8
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
9
9
|
import { formatTimeAgo } from './worktrees.js';
|
|
10
10
|
const MAX_ROWS = 20;
|
|
11
11
|
async function fetchRecentSessions({ client, }) {
|
|
@@ -104,7 +104,7 @@ export async function handleLastSessionsCommand({ command, }) {
|
|
|
104
104
|
}
|
|
105
105
|
const tableMarkdown = buildSessionTable({ rows });
|
|
106
106
|
const segments = splitTablesFromMarkdown(tableMarkdown);
|
|
107
|
-
const
|
|
107
|
+
const allComponents = segments.flatMap((segment) => {
|
|
108
108
|
if (segment.type === 'components') {
|
|
109
109
|
return segment.components;
|
|
110
110
|
}
|
|
@@ -114,6 +114,7 @@ export async function handleLastSessionsCommand({ command, }) {
|
|
|
114
114
|
};
|
|
115
115
|
return [textDisplay];
|
|
116
116
|
});
|
|
117
|
+
const { components } = truncateComponents(allComponents);
|
|
117
118
|
await command.editReply({
|
|
118
119
|
components,
|
|
119
120
|
flags: MessageFlags.IsComponentsV2,
|
|
@@ -9,7 +9,7 @@ import { notifyError } from '../sentry.js';
|
|
|
9
9
|
import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
|
|
10
10
|
import { sendThreadMessage, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
|
|
11
11
|
import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
12
|
-
import { RebaseConflictError, DirtyWorktreeError, TargetDirtyWorktreeError, } from '../errors.js';
|
|
12
|
+
import { RebaseConflictError, DirtyWorktreeError, TargetDirtyWorktreeError, NothingToMergeError, } from '../errors.js';
|
|
13
13
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
14
14
|
/** Worktree thread title prefix - indicates unmerged worktree */
|
|
15
15
|
export const WORKTREE_PREFIX = '⬦ ';
|
|
@@ -103,6 +103,11 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
103
103
|
await command.editReply('Merge failed: uncommitted changes in main. Commit changes in the main worktree first, then run `/merge-worktree` again.');
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
|
+
if (result instanceof NothingToMergeError) {
|
|
107
|
+
void removeWorktreePrefixFromTitle(thread);
|
|
108
|
+
await command.editReply(`Merge failed: ${result.message}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
106
111
|
if (result instanceof RebaseConflictError) {
|
|
107
112
|
await command.editReply('Rebase conflict detected. Asking the model to resolve...');
|
|
108
113
|
await sendPromptToModel({
|
package/dist/commands/model.js
CHANGED
|
@@ -104,6 +104,28 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
|
|
|
104
104
|
});
|
|
105
105
|
modelLogger.log(`[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`);
|
|
106
106
|
}
|
|
107
|
+
export async function copyCurrentSessionModel({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
|
|
108
|
+
const modelInfo = await getCurrentModelInfo({
|
|
109
|
+
sessionId: sourceSessionId,
|
|
110
|
+
channelId,
|
|
111
|
+
appId,
|
|
112
|
+
getClient,
|
|
113
|
+
directory,
|
|
114
|
+
});
|
|
115
|
+
if (modelInfo.type === 'none')
|
|
116
|
+
return;
|
|
117
|
+
const variant = await getVariantCascade({
|
|
118
|
+
sessionId: sourceSessionId,
|
|
119
|
+
channelId,
|
|
120
|
+
appId,
|
|
121
|
+
});
|
|
122
|
+
await setSessionModel({
|
|
123
|
+
sessionId: targetSessionId,
|
|
124
|
+
modelId: modelInfo.model,
|
|
125
|
+
variant: variant ?? null,
|
|
126
|
+
});
|
|
127
|
+
modelLogger.log(`[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
|
|
128
|
+
}
|
|
107
129
|
/**
|
|
108
130
|
* Get the current model info for a channel/session, including where it comes from.
|
|
109
131
|
* Priority: session > agent > channel > global > opencode default
|
|
@@ -196,16 +218,11 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
196
218
|
return;
|
|
197
219
|
}
|
|
198
220
|
// Determine if we're in a thread or text channel
|
|
199
|
-
const
|
|
200
|
-
ChannelType.PublicThread,
|
|
201
|
-
ChannelType.PrivateThread,
|
|
202
|
-
ChannelType.AnnouncementThread,
|
|
203
|
-
].includes(channel.type);
|
|
221
|
+
const thread = channel.isThread() ? channel : undefined;
|
|
204
222
|
let projectDirectory;
|
|
205
223
|
let targetChannelId;
|
|
206
224
|
let sessionId;
|
|
207
|
-
if (
|
|
208
|
-
const thread = channel;
|
|
225
|
+
if (thread) {
|
|
209
226
|
// Parallelize: resolve metadata and session ID at the same time
|
|
210
227
|
const [textChannel, threadSessionId] = await Promise.all([
|
|
211
228
|
resolveTextChannel(thread),
|
|
@@ -240,7 +257,7 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
240
257
|
return;
|
|
241
258
|
}
|
|
242
259
|
const effectiveAppId = appId;
|
|
243
|
-
if (
|
|
260
|
+
if (thread && sessionId) {
|
|
244
261
|
await ensureSessionPreferencesSnapshot({
|
|
245
262
|
sessionId,
|
|
246
263
|
channelId: targetChannelId,
|
|
@@ -313,8 +330,8 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
313
330
|
dir: projectDirectory,
|
|
314
331
|
channelId: targetChannelId,
|
|
315
332
|
sessionId: sessionId,
|
|
316
|
-
isThread:
|
|
317
|
-
thread
|
|
333
|
+
isThread: Boolean(thread),
|
|
334
|
+
thread,
|
|
318
335
|
appId,
|
|
319
336
|
providerSelectHeader,
|
|
320
337
|
};
|
|
@@ -13,6 +13,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
|
|
|
13
13
|
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
14
14
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
15
15
|
import * as errore from 'errore';
|
|
16
|
+
import { copyCurrentSessionModel } from './model.js';
|
|
16
17
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
17
18
|
const DEFAULT_WORKTREE_BASE_REF = 'HEAD';
|
|
18
19
|
async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
|
|
@@ -434,6 +435,14 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
|
|
|
434
435
|
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
|
|
435
436
|
return;
|
|
436
437
|
}
|
|
438
|
+
await copyCurrentSessionModel({
|
|
439
|
+
sourceSessionId,
|
|
440
|
+
targetSessionId: forkedSession.id,
|
|
441
|
+
channelId: parent.id,
|
|
442
|
+
appId,
|
|
443
|
+
getClient,
|
|
444
|
+
directory: projectDirectory,
|
|
445
|
+
});
|
|
437
446
|
const permissionResponse = await getClient().session.update({
|
|
438
447
|
sessionID: forkedSession.id,
|
|
439
448
|
directory: result,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
|
|
8
8
|
import { deleteThreadWorktree, } from '../database.js';
|
|
9
9
|
import { getDb } from '../db.js';
|
|
10
|
-
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
10
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
11
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
12
12
|
import * as errore from 'errore';
|
|
13
13
|
import crypto from 'node:crypto';
|
|
@@ -132,7 +132,9 @@ function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
|
|
|
132
132
|
return parts.join(', ');
|
|
133
133
|
})();
|
|
134
134
|
const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
|
|
135
|
-
|
|
135
|
+
// Show only the last 2 path segments to keep text size under Discord's
|
|
136
|
+
// 4000-char displayable text limit. Full paths are too long.
|
|
137
|
+
const folder = `…/${path.basename(path.dirname(row.directory))}/${path.basename(row.directory)}`;
|
|
136
138
|
const action = buildActionCell({ row, gitStatus: gs });
|
|
137
139
|
return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
|
|
138
140
|
});
|
|
@@ -360,7 +362,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
360
362
|
return buildHtmlActionCustomId(actionId);
|
|
361
363
|
},
|
|
362
364
|
});
|
|
363
|
-
const
|
|
365
|
+
const allComponents = segments.flatMap((segment) => {
|
|
364
366
|
if (segment.type === 'components') {
|
|
365
367
|
return segment.components;
|
|
366
368
|
}
|
|
@@ -370,6 +372,20 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
370
372
|
};
|
|
371
373
|
return [textDisplay];
|
|
372
374
|
});
|
|
375
|
+
// Reserve budget for a truncation notice (1 component + its text length)
|
|
376
|
+
// so appending the notice doesn't push us over either Discord limit.
|
|
377
|
+
const truncatedNoticeContent = `*Some worktrees were not shown due to Discord's component limit. Use \`git worktree list\` for the full list.*`;
|
|
378
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
379
|
+
reserveCost: 1,
|
|
380
|
+
reserveTextSize: truncatedNoticeContent.length,
|
|
381
|
+
});
|
|
382
|
+
if (truncated) {
|
|
383
|
+
const truncatedNotice = {
|
|
384
|
+
type: ComponentType.TextDisplay,
|
|
385
|
+
content: truncatedNoticeContent,
|
|
386
|
+
};
|
|
387
|
+
components.push(truncatedNotice);
|
|
388
|
+
}
|
|
373
389
|
await editReply({
|
|
374
390
|
components,
|
|
375
391
|
flags: MessageFlags.IsComponentsV2,
|
package/dist/db.test.js
CHANGED
|
@@ -7,9 +7,10 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, createPendingWorktree, getSessionEventSnapshot, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, createPendingWorktree, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
|
|
11
11
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
12
12
|
import { chooseLockPort } from './test-utils.js';
|
|
13
|
+
import { copyCurrentSessionModel } from './commands/model.js';
|
|
13
14
|
afterAll(async () => {
|
|
14
15
|
await closeDb();
|
|
15
16
|
});
|
|
@@ -100,6 +101,31 @@ describe('getDb', () => {
|
|
|
100
101
|
await db.delete(schema.thread_worktrees).where(orm.eq(schema.thread_worktrees.thread_id, threadId));
|
|
101
102
|
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
102
103
|
});
|
|
104
|
+
test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
|
|
105
|
+
const db = await getDb();
|
|
106
|
+
const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
|
|
107
|
+
const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
|
|
108
|
+
const getClient = (() => {
|
|
109
|
+
throw new Error('provider lookup should not run for explicit session models');
|
|
110
|
+
});
|
|
111
|
+
await setSessionModel({
|
|
112
|
+
sessionId: sourceSessionId,
|
|
113
|
+
modelId: 'anthropic/claude-opus-4-6',
|
|
114
|
+
variant: 'thinking',
|
|
115
|
+
});
|
|
116
|
+
await copyCurrentSessionModel({
|
|
117
|
+
sourceSessionId,
|
|
118
|
+
targetSessionId,
|
|
119
|
+
getClient,
|
|
120
|
+
});
|
|
121
|
+
await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
|
|
122
|
+
{
|
|
123
|
+
"modelId": "anthropic/claude-opus-4-6",
|
|
124
|
+
"variant": "thinking",
|
|
125
|
+
}
|
|
126
|
+
`);
|
|
127
|
+
await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
|
|
128
|
+
});
|
|
103
129
|
test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
|
|
104
130
|
const db = await getDb();
|
|
105
131
|
const threadId = 'test-session-events-thread';
|
package/dist/format-tables.js
CHANGED
|
@@ -8,7 +8,144 @@ import { parseInlineHtmlRenderables, } from './html-components.js';
|
|
|
8
8
|
// Max 40 components per message (nested components count toward the limit).
|
|
9
9
|
// Row cost is dynamic now because a table row can render as a plain TextDisplay
|
|
10
10
|
// or as a TextDisplay plus an Action Row holding one or more buttons.
|
|
11
|
-
const MAX_COMPONENTS = 40;
|
|
11
|
+
export const MAX_COMPONENTS = 40;
|
|
12
|
+
// Discord caps total displayable text across all components at 4000 chars.
|
|
13
|
+
export const MAX_TEXT_SIZE = 4000;
|
|
14
|
+
// Count cost of a single child inside a Container.
|
|
15
|
+
// ActionRow with N buttons = 1 + N, everything else = 1.
|
|
16
|
+
function childComponentCost(child) {
|
|
17
|
+
if ('components' in child && Array.isArray(child.components)) {
|
|
18
|
+
return 1 + child.components.length;
|
|
19
|
+
}
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
// Count displayable text size of a component tree.
|
|
23
|
+
// Discord counts all text content (TextDisplay, button labels) toward a 4000-char limit.
|
|
24
|
+
function componentTextSize(component) {
|
|
25
|
+
let size = 0;
|
|
26
|
+
if ('content' in component && typeof component.content === 'string') {
|
|
27
|
+
size += component.content.length;
|
|
28
|
+
}
|
|
29
|
+
if ('label' in component && typeof component.label === 'string') {
|
|
30
|
+
size += component.label.length;
|
|
31
|
+
}
|
|
32
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
33
|
+
for (const child of component.components) {
|
|
34
|
+
size += componentTextSize(child);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return size;
|
|
38
|
+
}
|
|
39
|
+
// Count total component cost of a top-level component including nested children.
|
|
40
|
+
// Discord counts every component toward the 40-component budget:
|
|
41
|
+
// Container(3 children) = 4, ActionRow(2 buttons) = 3, TextDisplay = 1.
|
|
42
|
+
export function countComponentCost(component) {
|
|
43
|
+
if (component.type === ComponentType.Container) {
|
|
44
|
+
let cost = 1;
|
|
45
|
+
for (const child of component.components) {
|
|
46
|
+
cost += childComponentCost(child);
|
|
47
|
+
}
|
|
48
|
+
return cost;
|
|
49
|
+
}
|
|
50
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
51
|
+
return 1 + component.components.length;
|
|
52
|
+
}
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
// Truncate an array of top-level components to stay within Discord limits:
|
|
56
|
+
// - 40 total components (nested children count)
|
|
57
|
+
// - 4000 chars total displayable text
|
|
58
|
+
// When a Container alone exceeds the budget, its children are truncated instead
|
|
59
|
+
// of dropping the entire Container (which would show nothing).
|
|
60
|
+
// reserveCost / reserveTextSize hold back budget for caller-appended components.
|
|
61
|
+
// maxComponents / maxTextSize override defaults (useful for testing).
|
|
62
|
+
export function truncateComponents(components, { reserveCost = 0, reserveTextSize = 0, maxComponents = MAX_COMPONENTS, maxTextSize = MAX_TEXT_SIZE, } = {}) {
|
|
63
|
+
const componentBudget = maxComponents - reserveCost;
|
|
64
|
+
const textBudget = maxTextSize - reserveTextSize;
|
|
65
|
+
let totalCost = 0;
|
|
66
|
+
let totalText = 0;
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const component of components) {
|
|
69
|
+
const cost = countComponentCost(component);
|
|
70
|
+
const text = componentTextSize(component);
|
|
71
|
+
if (totalCost + cost <= componentBudget && totalText + text <= textBudget) {
|
|
72
|
+
result.push(component);
|
|
73
|
+
totalCost += cost;
|
|
74
|
+
totalText += text;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// The component doesn't fit. If it's a Container, truncate its children
|
|
78
|
+
// to fill the remaining budget instead of dropping the whole thing.
|
|
79
|
+
if (component.type === ComponentType.Container) {
|
|
80
|
+
const remainingComponentBudget = componentBudget - totalCost - 1;
|
|
81
|
+
const remainingTextBudget = textBudget - totalText;
|
|
82
|
+
if (remainingComponentBudget > 0 && remainingTextBudget > 0) {
|
|
83
|
+
const truncatedChildren = truncateContainerChildren(component.components, remainingComponentBudget, remainingTextBudget);
|
|
84
|
+
if (truncatedChildren.length > 0) {
|
|
85
|
+
result.push({
|
|
86
|
+
...component,
|
|
87
|
+
components: truncatedChildren,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { components: result, truncated: true };
|
|
93
|
+
}
|
|
94
|
+
return { components: result, truncated: false };
|
|
95
|
+
}
|
|
96
|
+
// Truncate a Container's children by separator-delimited row groups.
|
|
97
|
+
// A row group is everything between separators (e.g. [TextDisplay, ActionRow]).
|
|
98
|
+
// Either the full group fits or it's excluded — no trailing separators or
|
|
99
|
+
// partial rows (like a TextDisplay without its ActionRow button).
|
|
100
|
+
// Enforces both component count and text size budgets.
|
|
101
|
+
function truncateContainerChildren(children, componentBudget, textBudget) {
|
|
102
|
+
const groups = groupBySeparator(children);
|
|
103
|
+
let cost = 0;
|
|
104
|
+
let text = 0;
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const group of groups) {
|
|
107
|
+
const groupCost = group.reduce((sum, child) => sum + childComponentCost(child), 0);
|
|
108
|
+
const groupText = group.reduce((sum, child) => sum + componentTextSize(child), 0);
|
|
109
|
+
if (cost + groupCost > componentBudget || text + groupText > textBudget) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
result.push(...group);
|
|
113
|
+
cost += groupCost;
|
|
114
|
+
text += groupText;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
// Split children into groups delimited by Separator components.
|
|
119
|
+
// Separators are prepended to the following group (they sit between rows).
|
|
120
|
+
// First group has no leading separator.
|
|
121
|
+
// Drops separator-only groups and strips leading separators from the first group
|
|
122
|
+
// to handle edge cases (children starting/ending with separators, consecutive separators).
|
|
123
|
+
function groupBySeparator(children) {
|
|
124
|
+
const rawGroups = [];
|
|
125
|
+
let current = [];
|
|
126
|
+
for (const child of children) {
|
|
127
|
+
if (child.type === ComponentType.Separator && current.length > 0) {
|
|
128
|
+
rawGroups.push(current);
|
|
129
|
+
current = [child];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
current.push(child);
|
|
133
|
+
}
|
|
134
|
+
if (current.length > 0) {
|
|
135
|
+
rawGroups.push(current);
|
|
136
|
+
}
|
|
137
|
+
// Clean up: strip leading separators from first group, drop separator-only groups
|
|
138
|
+
return rawGroups
|
|
139
|
+
.map((group, index) => {
|
|
140
|
+
if (index === 0) {
|
|
141
|
+
return group.filter((child) => child.type !== ComponentType.Separator);
|
|
142
|
+
}
|
|
143
|
+
return group;
|
|
144
|
+
})
|
|
145
|
+
.filter((group) => {
|
|
146
|
+
return group.some((child) => child.type !== ComponentType.Separator);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
12
149
|
/**
|
|
13
150
|
* Split markdown into text and table component segments.
|
|
14
151
|
* Tables are rendered as CV2 Container components with bold key-value TextDisplay
|