kimaki 0.15.0 → 0.17.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/agent-model.e2e.test.js +74 -1
- package/dist/cli-commands/send.js +1 -0
- package/dist/cli-runner.js +29 -3
- package/dist/commands/btw.js +12 -3
- 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/db.test.js +27 -1
- package/dist/opencode.js +20 -0
- package/dist/system-message.js +1 -1
- package/dist/system-message.test.js +1 -1
- package/package.json +4 -4
- package/src/agent-model.e2e.test.ts +90 -2
- package/src/cli-commands/send.ts +1 -0
- package/src/cli-runner.ts +32 -2
- package/src/commands/btw.ts +13 -3
- 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/db.test.ts +34 -0
- package/src/opencode.ts +20 -0
- package/src/system-message.test.ts +1 -1
- package/src/system-message.ts +1 -1
|
@@ -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');
|
|
@@ -457,6 +457,7 @@ cli
|
|
|
457
457
|
botToken,
|
|
458
458
|
embeds: autoStartEmbed,
|
|
459
459
|
rest,
|
|
460
|
+
splitInsteadOfAttach: notifyOnly,
|
|
460
461
|
});
|
|
461
462
|
// For notify-only on non-project channels, just post the message without
|
|
462
463
|
// creating a thread. There's no session to start, so a thread is unnecessary.
|
package/dist/cli-runner.js
CHANGED
|
@@ -107,7 +107,7 @@ export function isThreadChannelType(type) {
|
|
|
107
107
|
ChannelType.AnnouncementThread,
|
|
108
108
|
].includes(type);
|
|
109
109
|
}
|
|
110
|
-
export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, }) {
|
|
110
|
+
export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, }) {
|
|
111
111
|
const discordMaxLength = 2000;
|
|
112
112
|
if (prompt.length <= discordMaxLength) {
|
|
113
113
|
return (await rest.post(Routes.channelMessages(channelId), {
|
|
@@ -118,6 +118,33 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
118
118
|
},
|
|
119
119
|
}));
|
|
120
120
|
}
|
|
121
|
+
if (splitInsteadOfAttach) {
|
|
122
|
+
const { splitMarkdownForDiscord } = await import('./discord-utils.js');
|
|
123
|
+
const chunks = splitMarkdownForDiscord({
|
|
124
|
+
content: prompt,
|
|
125
|
+
maxLength: discordMaxLength,
|
|
126
|
+
});
|
|
127
|
+
let firstMessage;
|
|
128
|
+
for (let chunk of chunks) {
|
|
129
|
+
if (!chunk?.trim())
|
|
130
|
+
continue;
|
|
131
|
+
// Safety net: hard-truncate if splitting still produced an oversized chunk
|
|
132
|
+
if (chunk.length > discordMaxLength) {
|
|
133
|
+
chunk = chunk.slice(0, discordMaxLength - 4) + '...';
|
|
134
|
+
}
|
|
135
|
+
const message = (await rest.post(Routes.channelMessages(channelId), {
|
|
136
|
+
body: {
|
|
137
|
+
content: chunk,
|
|
138
|
+
// Only attach embeds to the first message
|
|
139
|
+
...(firstMessage ? {} : { embeds }),
|
|
140
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
if (!firstMessage)
|
|
144
|
+
firstMessage = message;
|
|
145
|
+
}
|
|
146
|
+
return firstMessage;
|
|
147
|
+
}
|
|
121
148
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
122
149
|
const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
|
|
123
150
|
const tmpDir = path.join(process.cwd(), 'tmp');
|
|
@@ -759,8 +786,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
759
786
|
options: [
|
|
760
787
|
{
|
|
761
788
|
value: 'gateway',
|
|
762
|
-
|
|
763
|
-
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
789
|
+
label: 'Gateway (pre-built Kimaki bot, no setup needed)',
|
|
764
790
|
},
|
|
765
791
|
{
|
|
766
792
|
value: 'self_hosted',
|
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({
|
|
@@ -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,
|
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/opencode.js
CHANGED
|
@@ -529,6 +529,26 @@ async function startSingleServer({ directory, } = {}) {
|
|
|
529
529
|
experimental: {
|
|
530
530
|
continue_loop_on_deny: true,
|
|
531
531
|
},
|
|
532
|
+
provider: {
|
|
533
|
+
xai: {
|
|
534
|
+
models: {
|
|
535
|
+
'grok-composer-2.5-fast': {
|
|
536
|
+
name: 'Grok Composer 2.5 Fast',
|
|
537
|
+
attachment: true,
|
|
538
|
+
tool_call: true,
|
|
539
|
+
limit: {
|
|
540
|
+
context: 256000,
|
|
541
|
+
output: 256000,
|
|
542
|
+
},
|
|
543
|
+
cost: {
|
|
544
|
+
input: 0.50,
|
|
545
|
+
output: 2.50,
|
|
546
|
+
cache_read: 0.20,
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
532
552
|
skills: {
|
|
533
553
|
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
534
554
|
},
|
package/dist/system-message.js
CHANGED
|
@@ -535,7 +535,7 @@ When you are approaching the **context window limit** or the user explicitly ask
|
|
|
535
535
|
kimaki send --channel ${channelId} --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent>${userArg}
|
|
536
536
|
\`\`\`
|
|
537
537
|
|
|
538
|
-
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
538
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
|
|
539
539
|
|
|
540
540
|
Use this for handoff when:
|
|
541
541
|
- User asks to "handoff", "continue in new thread", or "start fresh session"
|
|
@@ -296,7 +296,7 @@ describe('system-message', () => {
|
|
|
296
296
|
kimaki send --channel chan_123 --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent> --user '<discord-user-id>'
|
|
297
297
|
\`\`\`
|
|
298
298
|
|
|
299
|
-
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
299
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
|
|
300
300
|
|
|
301
301
|
Use this for handoff when:
|
|
302
302
|
- User asks to "handoff", "continue in new thread", or "start fresh session"
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.17.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"tsx": "^4.20.5",
|
|
26
26
|
"undici": "^8.0.2",
|
|
27
27
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
-
"opencode-deterministic-provider": "^0.0.1",
|
|
29
28
|
"opencode-cached-provider": "^0.0.1",
|
|
29
|
+
"opencode-deterministic-provider": "^0.0.1",
|
|
30
30
|
"db": "^0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
@@ -62,10 +62,10 @@
|
|
|
62
62
|
"yaml": "^2.8.3",
|
|
63
63
|
"zod": "^4.3.6",
|
|
64
64
|
"zustand": "^5.0.11",
|
|
65
|
-
"traforo": "^0.7.0",
|
|
66
65
|
"errore": "^0.14.1",
|
|
67
66
|
"libsqlproxy": "^0.1.0",
|
|
68
|
-
"opencode-injection-guard": "^0.2.1"
|
|
67
|
+
"opencode-injection-guard": "^0.2.1",
|
|
68
|
+
"traforo": "^0.7.1"
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
71
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -39,8 +39,10 @@ import {
|
|
|
39
39
|
setChannelAgent,
|
|
40
40
|
setChannelModel,
|
|
41
41
|
getThreadSession,
|
|
42
|
+
getSessionModel,
|
|
42
43
|
getSessionAgent,
|
|
43
44
|
getChannelAgent,
|
|
45
|
+
setSessionModel,
|
|
44
46
|
type VerbosityLevel,
|
|
45
47
|
} from './database.js'
|
|
46
48
|
import { getDb } from './db.js'
|
|
@@ -281,8 +283,9 @@ describe('agent model resolution', () => {
|
|
|
281
283
|
})
|
|
282
284
|
|
|
283
285
|
// Add extra models to the provider so opencode accepts them
|
|
284
|
-
const providerConfig = opencodeConfig.provider[PROVIDER_NAME]
|
|
285
|
-
|
|
286
|
+
const providerConfig = opencodeConfig.provider[PROVIDER_NAME]
|
|
287
|
+
if (!providerConfig) {
|
|
288
|
+
throw new Error(`Missing deterministic provider config for ${PROVIDER_NAME}`)
|
|
286
289
|
}
|
|
287
290
|
providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL }
|
|
288
291
|
providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL }
|
|
@@ -700,6 +703,91 @@ describe('agent model resolution', () => {
|
|
|
700
703
|
15_000,
|
|
701
704
|
)
|
|
702
705
|
|
|
706
|
+
test(
|
|
707
|
+
'/btw fork keeps source session model when channel model differs',
|
|
708
|
+
async () => {
|
|
709
|
+
const db = await getDb()
|
|
710
|
+
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID))
|
|
711
|
+
await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID))
|
|
712
|
+
|
|
713
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
714
|
+
content: 'Reply with exactly: btw-source-msg',
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
718
|
+
timeout: 4_000,
|
|
719
|
+
predicate: (t) => {
|
|
720
|
+
return t.name === 'Reply with exactly: btw-source-msg'
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
await waitForFooterMessage({
|
|
725
|
+
discord,
|
|
726
|
+
threadId: sourceThread.id,
|
|
727
|
+
timeout: 6_000,
|
|
728
|
+
afterMessageIncludes: 'ok',
|
|
729
|
+
afterAuthorId: discord.botUserId,
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
const sourceSessionId = await getThreadSession(sourceThread.id)
|
|
733
|
+
expect(sourceSessionId).toBeDefined()
|
|
734
|
+
if (!sourceSessionId) throw new Error('Expected source session')
|
|
735
|
+
|
|
736
|
+
await setSessionModel({
|
|
737
|
+
sessionId: sourceSessionId,
|
|
738
|
+
modelId: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
|
|
739
|
+
})
|
|
740
|
+
await setChannelModel({
|
|
741
|
+
channelId: TEXT_CHANNEL_ID,
|
|
742
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
|
|
746
|
+
content: 'Reply with exactly: btw-model-check. btw',
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
750
|
+
timeout: 4_000,
|
|
751
|
+
predicate: (t) => {
|
|
752
|
+
return t.name === 'btw: Reply with exactly: btw-model-check'
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
await waitForFooterMessage({
|
|
757
|
+
discord,
|
|
758
|
+
threadId: forkedThread.id,
|
|
759
|
+
timeout: 6_000,
|
|
760
|
+
afterMessageIncludes: 'ok',
|
|
761
|
+
afterAuthorId: discord.botUserId,
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
const forkedSessionId = await getThreadSession(forkedThread.id)
|
|
765
|
+
expect(forkedSessionId).toBeDefined()
|
|
766
|
+
const forkedSessionModel = forkedSessionId
|
|
767
|
+
? await getSessionModel(forkedSessionId)
|
|
768
|
+
: undefined
|
|
769
|
+
|
|
770
|
+
const forkedThreadText = (await discord.thread(forkedThread.id).text())
|
|
771
|
+
.replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>')
|
|
772
|
+
|
|
773
|
+
expect(forkedThreadText).toMatchInlineSnapshot(`
|
|
774
|
+
"--- from: assistant (TestBot)
|
|
775
|
+
Reusing context from <#SOURCE_THREAD> to answer prompt...
|
|
776
|
+
Reply with exactly: btw-model-check
|
|
777
|
+
⬥ ok
|
|
778
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
|
|
779
|
+
`)
|
|
780
|
+
expect(forkedSessionModel).toMatchInlineSnapshot(`
|
|
781
|
+
{
|
|
782
|
+
"modelId": "deterministic-provider/plan-model-v2",
|
|
783
|
+
"variant": null,
|
|
784
|
+
}
|
|
785
|
+
`)
|
|
786
|
+
expect(forkedSessionModel?.modelId).not.toBe(`${PROVIDER_NAME}/${CHANNEL_MODEL}`)
|
|
787
|
+
},
|
|
788
|
+
20_000,
|
|
789
|
+
)
|
|
790
|
+
|
|
703
791
|
test(
|
|
704
792
|
'changing channel agent via /plan-agent does not affect existing thread model',
|
|
705
793
|
async () => {
|
package/src/cli-commands/send.ts
CHANGED
package/src/cli-runner.ts
CHANGED
|
@@ -172,12 +172,17 @@ export async function sendDiscordMessageWithOptionalAttachment({
|
|
|
172
172
|
botToken,
|
|
173
173
|
embeds,
|
|
174
174
|
rest,
|
|
175
|
+
splitInsteadOfAttach,
|
|
175
176
|
}: {
|
|
176
177
|
channelId: string
|
|
177
178
|
prompt: string
|
|
178
179
|
botToken: string
|
|
179
180
|
embeds?: Array<{ color: number; footer: { text: string } }>
|
|
180
181
|
rest: REST
|
|
182
|
+
/** When true, long messages are split into multiple Discord messages instead of
|
|
183
|
+
* being attached as a file. Useful for notify-only messages where the content
|
|
184
|
+
* should be directly visible in the channel. */
|
|
185
|
+
splitInsteadOfAttach?: boolean
|
|
181
186
|
}): Promise<{ id: string }> {
|
|
182
187
|
const discordMaxLength = 2000
|
|
183
188
|
if (prompt.length <= discordMaxLength) {
|
|
@@ -190,6 +195,32 @@ export async function sendDiscordMessageWithOptionalAttachment({
|
|
|
190
195
|
})) as { id: string }
|
|
191
196
|
}
|
|
192
197
|
|
|
198
|
+
if (splitInsteadOfAttach) {
|
|
199
|
+
const { splitMarkdownForDiscord } = await import('./discord-utils.js')
|
|
200
|
+
const chunks = splitMarkdownForDiscord({
|
|
201
|
+
content: prompt,
|
|
202
|
+
maxLength: discordMaxLength,
|
|
203
|
+
})
|
|
204
|
+
let firstMessage: { id: string } | undefined
|
|
205
|
+
for (let chunk of chunks) {
|
|
206
|
+
if (!chunk?.trim()) continue
|
|
207
|
+
// Safety net: hard-truncate if splitting still produced an oversized chunk
|
|
208
|
+
if (chunk.length > discordMaxLength) {
|
|
209
|
+
chunk = chunk.slice(0, discordMaxLength - 4) + '...'
|
|
210
|
+
}
|
|
211
|
+
const message = (await rest.post(Routes.channelMessages(channelId), {
|
|
212
|
+
body: {
|
|
213
|
+
content: chunk,
|
|
214
|
+
// Only attach embeds to the first message
|
|
215
|
+
...(firstMessage ? {} : { embeds }),
|
|
216
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
217
|
+
},
|
|
218
|
+
})) as { id: string }
|
|
219
|
+
if (!firstMessage) firstMessage = message
|
|
220
|
+
}
|
|
221
|
+
return firstMessage!
|
|
222
|
+
}
|
|
223
|
+
|
|
193
224
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
|
|
194
225
|
const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`
|
|
195
226
|
|
|
@@ -1109,8 +1140,7 @@ export async function resolveCredentials({
|
|
|
1109
1140
|
options: [
|
|
1110
1141
|
{
|
|
1111
1142
|
value: 'gateway' as const,
|
|
1112
|
-
|
|
1113
|
-
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
1143
|
+
label: 'Gateway (pre-built Kimaki bot, no setup needed)',
|
|
1114
1144
|
},
|
|
1115
1145
|
{
|
|
1116
1146
|
value: 'self_hosted' as const,
|
package/src/commands/btw.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
|
|
|
19
19
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
20
20
|
import type { CommandContext } from './types.js'
|
|
21
21
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
22
|
+
import { copyCurrentSessionModel } from './model.js'
|
|
22
23
|
|
|
23
24
|
const logger = createLogger(LogPrefix.FORK)
|
|
24
25
|
|
|
@@ -62,6 +63,16 @@ export async function forkSessionToBtwThread({
|
|
|
62
63
|
return new Error('Failed to fork session')
|
|
63
64
|
}
|
|
64
65
|
const forkedSession = forkResponse.data
|
|
66
|
+
const channelId = sourceThread.parentId || sourceThread.id
|
|
67
|
+
|
|
68
|
+
await copyCurrentSessionModel({
|
|
69
|
+
sourceSessionId: sessionId,
|
|
70
|
+
targetSessionId: forkedSession.id,
|
|
71
|
+
channelId,
|
|
72
|
+
appId,
|
|
73
|
+
getClient: getClientResult,
|
|
74
|
+
directory: projectDirectory,
|
|
75
|
+
})
|
|
65
76
|
|
|
66
77
|
const thread = await textChannel.threads.create({
|
|
67
78
|
name: `btw: ${prompt}`.slice(0, 100),
|
|
@@ -92,8 +103,7 @@ export async function forkSessionToBtwThread({
|
|
|
92
103
|
`Do NOT continue, resume, or reference the previous task. Only answer the question below.`,
|
|
93
104
|
``,
|
|
94
105
|
`Parent session: ${sessionId} (thread <#${sourceThread.id}>)`,
|
|
95
|
-
`
|
|
96
|
-
` kimaki send --session ${sessionId} --prompt 'your message here'`,
|
|
106
|
+
`Do NOT send messages to the parent session unless the user explicitly asks you to.`,
|
|
97
107
|
``,
|
|
98
108
|
prompt,
|
|
99
109
|
].join('\n')
|
|
@@ -103,7 +113,7 @@ export async function forkSessionToBtwThread({
|
|
|
103
113
|
thread,
|
|
104
114
|
projectDirectory,
|
|
105
115
|
sdkDirectory: projectDirectory,
|
|
106
|
-
channelId
|
|
116
|
+
channelId,
|
|
107
117
|
appId,
|
|
108
118
|
})
|
|
109
119
|
await runtime.enqueueIncoming({
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
RebaseConflictError,
|
|
26
26
|
DirtyWorktreeError,
|
|
27
27
|
TargetDirtyWorktreeError,
|
|
28
|
+
NothingToMergeError,
|
|
28
29
|
} from '../errors.js'
|
|
29
30
|
|
|
30
31
|
const logger = createLogger(LogPrefix.WORKTREE)
|
|
@@ -160,6 +161,12 @@ export async function handleMergeWorktreeCommand({
|
|
|
160
161
|
return
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
if (result instanceof NothingToMergeError) {
|
|
165
|
+
void removeWorktreePrefixFromTitle(thread)
|
|
166
|
+
await command.editReply(`Merge failed: ${result.message}`)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
if (result instanceof RebaseConflictError) {
|
|
164
171
|
await command.editReply(
|
|
165
172
|
'Rebase conflict detected. Asking the model to resolve...',
|
package/src/commands/model.ts
CHANGED
|
@@ -248,6 +248,45 @@ export async function ensureSessionPreferencesSnapshot({
|
|
|
248
248
|
)
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
export async function copyCurrentSessionModel({
|
|
252
|
+
sourceSessionId,
|
|
253
|
+
targetSessionId,
|
|
254
|
+
channelId,
|
|
255
|
+
appId,
|
|
256
|
+
getClient,
|
|
257
|
+
directory,
|
|
258
|
+
}: {
|
|
259
|
+
sourceSessionId: string
|
|
260
|
+
targetSessionId: string
|
|
261
|
+
channelId?: string
|
|
262
|
+
appId?: string
|
|
263
|
+
getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
|
|
264
|
+
directory?: string
|
|
265
|
+
}) {
|
|
266
|
+
const modelInfo = await getCurrentModelInfo({
|
|
267
|
+
sessionId: sourceSessionId,
|
|
268
|
+
channelId,
|
|
269
|
+
appId,
|
|
270
|
+
getClient,
|
|
271
|
+
directory,
|
|
272
|
+
})
|
|
273
|
+
if (modelInfo.type === 'none') return
|
|
274
|
+
|
|
275
|
+
const variant = await getVariantCascade({
|
|
276
|
+
sessionId: sourceSessionId,
|
|
277
|
+
channelId,
|
|
278
|
+
appId,
|
|
279
|
+
})
|
|
280
|
+
await setSessionModel({
|
|
281
|
+
sessionId: targetSessionId,
|
|
282
|
+
modelId: modelInfo.model,
|
|
283
|
+
variant: variant ?? null,
|
|
284
|
+
})
|
|
285
|
+
modelLogger.log(
|
|
286
|
+
`[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
251
290
|
/**
|
|
252
291
|
* Get the current model info for a channel/session, including where it comes from.
|
|
253
292
|
* Priority: session > agent > channel > global > opencode default
|
|
@@ -372,18 +411,13 @@ export async function handleModelCommand({
|
|
|
372
411
|
}
|
|
373
412
|
|
|
374
413
|
// Determine if we're in a thread or text channel
|
|
375
|
-
const
|
|
376
|
-
ChannelType.PublicThread,
|
|
377
|
-
ChannelType.PrivateThread,
|
|
378
|
-
ChannelType.AnnouncementThread,
|
|
379
|
-
].includes(channel.type)
|
|
414
|
+
const thread = channel.isThread() ? channel : undefined
|
|
380
415
|
|
|
381
416
|
let projectDirectory: string | undefined
|
|
382
417
|
let targetChannelId: string
|
|
383
418
|
let sessionId: string | undefined
|
|
384
419
|
|
|
385
|
-
if (
|
|
386
|
-
const thread = channel as ThreadChannel
|
|
420
|
+
if (thread) {
|
|
387
421
|
// Parallelize: resolve metadata and session ID at the same time
|
|
388
422
|
const [textChannel, threadSessionId] = await Promise.all([
|
|
389
423
|
resolveTextChannel(thread),
|
|
@@ -420,7 +454,7 @@ export async function handleModelCommand({
|
|
|
420
454
|
|
|
421
455
|
const effectiveAppId = appId
|
|
422
456
|
|
|
423
|
-
if (
|
|
457
|
+
if (thread && sessionId) {
|
|
424
458
|
await ensureSessionPreferencesSnapshot({
|
|
425
459
|
sessionId,
|
|
426
460
|
channelId: targetChannelId,
|
|
@@ -503,8 +537,8 @@ export async function handleModelCommand({
|
|
|
503
537
|
dir: projectDirectory,
|
|
504
538
|
channelId: targetChannelId,
|
|
505
539
|
sessionId: sessionId,
|
|
506
|
-
isThread:
|
|
507
|
-
thread
|
|
540
|
+
isThread: Boolean(thread),
|
|
541
|
+
thread,
|
|
508
542
|
appId,
|
|
509
543
|
providerSelectHeader,
|
|
510
544
|
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
import { WORKTREE_PREFIX } from './merge-worktree.js'
|
|
44
44
|
import type { AutocompleteContext } from './types.js'
|
|
45
45
|
import * as errore from 'errore'
|
|
46
|
+
import { copyCurrentSessionModel } from './model.js'
|
|
46
47
|
|
|
47
48
|
const logger = createLogger(LogPrefix.WORKTREE)
|
|
48
49
|
const DEFAULT_WORKTREE_BASE_REF = 'HEAD'
|
|
@@ -598,6 +599,15 @@ async function handleWorktreeInThread({
|
|
|
598
599
|
return
|
|
599
600
|
}
|
|
600
601
|
|
|
602
|
+
await copyCurrentSessionModel({
|
|
603
|
+
sourceSessionId,
|
|
604
|
+
targetSessionId: forkedSession.id,
|
|
605
|
+
channelId: parent.id,
|
|
606
|
+
appId,
|
|
607
|
+
getClient,
|
|
608
|
+
directory: projectDirectory,
|
|
609
|
+
})
|
|
610
|
+
|
|
601
611
|
const permissionResponse = await getClient().session.update({
|
|
602
612
|
sessionID: forkedSession.id,
|
|
603
613
|
directory: result,
|
package/src/db.test.ts
CHANGED
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
appendSessionEventsSinceLastTimestamp,
|
|
13
13
|
createPendingWorktree,
|
|
14
14
|
getSessionEventSnapshot,
|
|
15
|
+
getSessionModel,
|
|
16
|
+
setSessionModel,
|
|
15
17
|
} from './database.js'
|
|
16
18
|
import { startHranaServer, stopHranaServer } from './hrana-server.js'
|
|
17
19
|
import { chooseLockPort } from './test-utils.js'
|
|
20
|
+
import { copyCurrentSessionModel } from './commands/model.js'
|
|
21
|
+
import type { initializeOpencodeForDirectory } from './opencode.js'
|
|
18
22
|
|
|
19
23
|
afterAll(async () => {
|
|
20
24
|
await closeDb()
|
|
@@ -118,6 +122,36 @@ describe('getDb', () => {
|
|
|
118
122
|
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId))
|
|
119
123
|
})
|
|
120
124
|
|
|
125
|
+
test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
|
|
126
|
+
const db = await getDb()
|
|
127
|
+
const sourceSessionId = `test-source-session-${crypto.randomUUID()}`
|
|
128
|
+
const targetSessionId = `test-target-session-${crypto.randomUUID()}`
|
|
129
|
+
const getClient = (() => {
|
|
130
|
+
throw new Error('provider lookup should not run for explicit session models')
|
|
131
|
+
}) satisfies Exclude<Awaited<ReturnType<typeof initializeOpencodeForDirectory>>, Error>
|
|
132
|
+
|
|
133
|
+
await setSessionModel({
|
|
134
|
+
sessionId: sourceSessionId,
|
|
135
|
+
modelId: 'anthropic/claude-opus-4-6',
|
|
136
|
+
variant: 'thinking',
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await copyCurrentSessionModel({
|
|
140
|
+
sourceSessionId,
|
|
141
|
+
targetSessionId,
|
|
142
|
+
getClient,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
|
|
146
|
+
{
|
|
147
|
+
"modelId": "anthropic/claude-opus-4-6",
|
|
148
|
+
"variant": "thinking",
|
|
149
|
+
}
|
|
150
|
+
`)
|
|
151
|
+
|
|
152
|
+
await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]))
|
|
153
|
+
})
|
|
154
|
+
|
|
121
155
|
test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
|
|
122
156
|
const db = await getDb()
|
|
123
157
|
const threadId = 'test-session-events-thread'
|
package/src/opencode.ts
CHANGED
|
@@ -735,6 +735,26 @@ async function startSingleServer({
|
|
|
735
735
|
experimental: {
|
|
736
736
|
continue_loop_on_deny: true,
|
|
737
737
|
},
|
|
738
|
+
provider: {
|
|
739
|
+
xai: {
|
|
740
|
+
models: {
|
|
741
|
+
'grok-composer-2.5-fast': {
|
|
742
|
+
name: 'Grok Composer 2.5 Fast',
|
|
743
|
+
attachment: true,
|
|
744
|
+
tool_call: true,
|
|
745
|
+
limit: {
|
|
746
|
+
context: 256000,
|
|
747
|
+
output: 256000,
|
|
748
|
+
},
|
|
749
|
+
cost: {
|
|
750
|
+
input: 0.50,
|
|
751
|
+
output: 2.50,
|
|
752
|
+
cache_read: 0.20,
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
},
|
|
738
758
|
skills: {
|
|
739
759
|
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
740
760
|
},
|
|
@@ -306,7 +306,7 @@ describe('system-message', () => {
|
|
|
306
306
|
kimaki send --channel chan_123 --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent> --user '<discord-user-id>'
|
|
307
307
|
\`\`\`
|
|
308
308
|
|
|
309
|
-
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
309
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
|
|
310
310
|
|
|
311
311
|
Use this for handoff when:
|
|
312
312
|
- User asks to "handoff", "continue in new thread", or "start fresh session"
|
package/src/system-message.ts
CHANGED
|
@@ -649,7 +649,7 @@ When you are approaching the **context window limit** or the user explicitly ask
|
|
|
649
649
|
kimaki send --channel ${channelId} --prompt 'Continuing from previous session: <summary of current task and state>' --agent <current_agent>${userArg}
|
|
650
650
|
\`\`\`
|
|
651
651
|
|
|
652
|
-
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
652
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments. With \`--notify-only\`, long prompts are split into multiple messages instead so the content is directly visible.
|
|
653
653
|
|
|
654
654
|
Use this for handoff when:
|
|
655
655
|
- User asks to "handoff", "continue in new thread", or "start fresh session"
|