kimaki 0.30.1 → 0.31.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 +27 -19
- package/dist/analytics.js +3 -1
- package/dist/btw-prefix-detection.js +9 -0
- package/dist/btw-prefix-detection.test.js +38 -1
- package/dist/cli-commands/send.js +2 -2
- package/dist/cli-runner.js +22 -10
- package/dist/cli-runner.test.js +34 -1
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/commands/btw.js +147 -39
- package/dist/commands/context-usage.js +47 -0
- package/dist/commands/context-usage.test.js +49 -0
- package/dist/commands/fork.js +32 -1
- package/dist/commands/model.js +69 -14
- package/dist/commands/new-worktree.js +5 -2
- package/dist/commands/queue.js +32 -25
- package/dist/commands/session.js +7 -2
- package/dist/db.test.js +17 -7
- package/dist/discord-bot.js +65 -42
- package/dist/errors.js +10 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/message-formatting.js +34 -9
- package/dist/message-formatting.test.js +2 -2
- package/dist/message-preprocessing.js +53 -75
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-footer.e2e.test.js +7 -7
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +146 -5
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +24 -14
- package/dist/queue-question-select-drain.e2e.test.js +27 -27
- package/dist/runtime-lifecycle.e2e.test.js +6 -6
- package/dist/session-handler/agent-utils.js +16 -17
- package/dist/session-handler/event-stream-state.js +10 -2
- package/dist/session-handler/event-stream-state.test.js +129 -3
- package/dist/session-handler/thread-session-runtime.js +169 -101
- package/dist/sleep.e2e.test.js +4 -4
- package/dist/system-message.js +214 -392
- package/dist/system-message.test.js +204 -380
- package/dist/test-utils.js +32 -6
- package/dist/thread-message-queue.e2e.test.js +131 -59
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-handler.js +2 -1
- package/dist/voice-message.e2e.test.js +22 -31
- package/dist/voice-routing-input.e2e.test.js +2 -2
- package/dist/voice-routing-question.e2e.test.js +59 -2
- package/dist/voice.js +306 -206
- package/dist/voice.test.js +161 -13
- package/dist/worktree-lifecycle.e2e.test.js +7 -7
- package/package.json +8 -11
- package/skills/critique/SKILL.md +4 -0
- package/skills/opensrc/SKILL.md +20 -13
- package/skills/playwriter/SKILL.md +1 -1
- package/src/agent-model.e2e.test.ts +28 -19
- package/src/analytics.ts +3 -1
- package/src/btw-prefix-detection.test.ts +39 -1
- package/src/btw-prefix-detection.ts +11 -0
- package/src/cli-commands/send.ts +8 -2
- package/src/cli-runner.test.ts +37 -0
- package/src/cli-runner.ts +26 -10
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/commands/btw.ts +151 -41
- package/src/commands/context-usage.test.ts +57 -0
- package/src/commands/context-usage.ts +59 -0
- package/src/commands/fork.ts +34 -1
- package/src/commands/model.ts +81 -14
- package/src/commands/new-worktree.ts +5 -2
- package/src/commands/queue.ts +41 -25
- package/src/commands/session.ts +10 -2
- package/src/db.test.ts +18 -6
- package/src/discord-bot.ts +67 -51
- package/src/errors.ts +12 -6
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/message-formatting.test.ts +2 -2
- package/src/message-formatting.ts +37 -10
- package/src/message-preprocessing.ts +69 -91
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-footer.e2e.test.ts +7 -7
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +164 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +24 -13
- package/src/queue-question-select-drain.e2e.test.ts +31 -28
- package/src/runtime-lifecycle.e2e.test.ts +6 -6
- package/src/session-handler/agent-utils.ts +25 -20
- package/src/session-handler/event-stream-state.test.ts +129 -2
- package/src/session-handler/event-stream-state.ts +21 -2
- package/src/session-handler/thread-runtime-state.ts +7 -0
- package/src/session-handler/thread-session-runtime.ts +211 -123
- package/src/sleep.e2e.test.ts +4 -4
- package/src/system-message.test.ts +216 -379
- package/src/system-message.ts +253 -389
- package/src/test-utils.ts +55 -12
- package/src/thread-message-queue.e2e.test.ts +135 -62
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-handler.ts +2 -1
- package/src/voice-message.e2e.test.ts +22 -33
- package/src/voice-routing-input.e2e.test.ts +2 -2
- package/src/voice-routing-question.e2e.test.ts +59 -2
- package/src/voice.test.ts +164 -14
- package/src/voice.ts +400 -279
- package/src/worktree-lifecycle.e2e.test.ts +7 -7
|
@@ -6,6 +6,45 @@ import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.j
|
|
|
6
6
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
const logger = createLogger(LogPrefix.SESSION);
|
|
9
|
+
export function formatContextBreakdown({ messages, lastAssistantId, inputTokens, }) {
|
|
10
|
+
if (inputTokens <= 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
const lastIndex = messages.findIndex((message) => message.info.id === lastAssistantId);
|
|
13
|
+
if (lastIndex < 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
const history = messages.slice(0, lastIndex);
|
|
16
|
+
const compactIndex = history.findLastIndex((message) => message.parts.some((part) => part.type === 'compaction'));
|
|
17
|
+
const active = history.slice(compactIndex < 0 ? 0 : compactIndex + 1);
|
|
18
|
+
const system = [...active].reverse().find((message) => message.info.role === 'user' && message.info.system);
|
|
19
|
+
const systemChars = system?.info.role === 'user' ? system.info.system?.length ?? 0 : 0;
|
|
20
|
+
const toolChars = new Map();
|
|
21
|
+
for (const message of active) {
|
|
22
|
+
if (message.info.role !== 'assistant')
|
|
23
|
+
continue;
|
|
24
|
+
for (const part of message.parts) {
|
|
25
|
+
if (part.type !== 'tool')
|
|
26
|
+
continue;
|
|
27
|
+
const output = part.state.status === 'completed' && !part.state.time.compacted
|
|
28
|
+
? part.state.output
|
|
29
|
+
: part.state.status === 'error' ? part.state.error : '';
|
|
30
|
+
const chars = JSON.stringify(part.state.input).length + output.length;
|
|
31
|
+
toolChars.set(part.tool, (toolChars.get(part.tool) ?? 0) + chars);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const estimates = [
|
|
35
|
+
{ name: 'system', tokens: Math.ceil(systemChars / 4) },
|
|
36
|
+
...[...toolChars].map(([name, chars]) => ({ name: `tool ${name}`, tokens: Math.ceil(chars / 4) })),
|
|
37
|
+
].sort((a, b) => b.tokens - a.tokens);
|
|
38
|
+
const estimated = estimates.reduce((total, entry) => total + entry.tokens, 0);
|
|
39
|
+
const scale = estimated > inputTokens ? inputTokens / estimated : 1;
|
|
40
|
+
const visible = estimates.slice(0, 5).map((entry) => ({
|
|
41
|
+
name: entry.name,
|
|
42
|
+
tokens: Math.floor(entry.tokens * scale),
|
|
43
|
+
}));
|
|
44
|
+
const other = inputTokens - visible.reduce((total, entry) => total + entry.tokens, 0);
|
|
45
|
+
const format = ({ name, tokens }) => `${name} ${(tokens / inputTokens * 100).toFixed(1)}% (${tokens.toLocaleString('en-US')})`;
|
|
46
|
+
return `**Estimated input mix:** ${[...visible.filter((entry) => entry.tokens > 0), { name: 'other', tokens: other }].map(format).join(' · ')} tokens (other includes messages and unexposed prompts)`;
|
|
47
|
+
}
|
|
9
48
|
function getTokenTotal({ input, output, reasoning, cache, }) {
|
|
10
49
|
return input + output + reasoning + cache.read + cache.write;
|
|
11
50
|
}
|
|
@@ -96,6 +135,7 @@ export async function handleContextUsageCommand({ command, }) {
|
|
|
96
135
|
}
|
|
97
136
|
const { tokens, modelID, providerID } = lastAssistant.info;
|
|
98
137
|
const totalTokens = getTokenTotal(tokens);
|
|
138
|
+
const inputTokens = tokens.input + tokens.cache.read + tokens.cache.write;
|
|
99
139
|
// Sum cost across all assistant messages for accurate session total
|
|
100
140
|
// (AssistantMessage.cost is per-message, not cumulative)
|
|
101
141
|
const totalCost = assistantMessages.reduce((sum, m) => {
|
|
@@ -129,6 +169,13 @@ export async function handleContextUsageCommand({ command, }) {
|
|
|
129
169
|
else {
|
|
130
170
|
lines.push(`**Context usage:** ${formattedTokens} tokens (context limit unavailable)`);
|
|
131
171
|
}
|
|
172
|
+
const breakdown = formatContextBreakdown({
|
|
173
|
+
messages,
|
|
174
|
+
lastAssistantId: lastAssistant.info.id,
|
|
175
|
+
inputTokens,
|
|
176
|
+
});
|
|
177
|
+
if (breakdown)
|
|
178
|
+
lines.push(breakdown);
|
|
132
179
|
if (modelID) {
|
|
133
180
|
lines.push(`**Model:** ${modelID}`);
|
|
134
181
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { formatContextBreakdown } from './context-usage.js';
|
|
3
|
+
const part = (messageID, value) => ({
|
|
4
|
+
id: `part-${messageID}`,
|
|
5
|
+
sessionID: 'session',
|
|
6
|
+
messageID,
|
|
7
|
+
...value,
|
|
8
|
+
});
|
|
9
|
+
const user = (id, system) => ({
|
|
10
|
+
id, sessionID: 'session', role: 'user', time: { created: 1 },
|
|
11
|
+
agent: 'build', model: { providerID: 'test', modelID: 'test' }, system,
|
|
12
|
+
});
|
|
13
|
+
const assistant = (id, input) => ({
|
|
14
|
+
id, sessionID: 'session', role: 'assistant', time: { created: 2 },
|
|
15
|
+
parentID: 'user', modelID: 'test', providerID: 'test', mode: 'build',
|
|
16
|
+
agent: 'build', path: { cwd: '/', root: '/' }, cost: 0,
|
|
17
|
+
tokens: { input, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
18
|
+
});
|
|
19
|
+
describe('formatContextBreakdown', () => {
|
|
20
|
+
test('attributes tool call inputs and outputs by type, and excludes the response to the measured prompt', () => {
|
|
21
|
+
const messages = [
|
|
22
|
+
{ info: user('user', 's'.repeat(40)), parts: [part('user', { type: 'text', text: 'u'.repeat(20) })] },
|
|
23
|
+
{ info: assistant('first', 20), parts: [
|
|
24
|
+
part('first', { type: 'tool', tool: 'read', callID: 'call', state: {
|
|
25
|
+
status: 'completed', input: { filePath: 'a'.repeat(28) }, output: 'o'.repeat(40),
|
|
26
|
+
title: 'read', metadata: {}, time: { start: 1, end: 2 },
|
|
27
|
+
} }),
|
|
28
|
+
] },
|
|
29
|
+
{ info: assistant('last', 50), parts: [part('last', { type: 'text', text: 'x'.repeat(800) })] },
|
|
30
|
+
];
|
|
31
|
+
expect(formatContextBreakdown({ messages, lastAssistantId: 'last', inputTokens: 50 }))
|
|
32
|
+
.toMatchInlineSnapshot(`"**Estimated input mix:** tool read 42.0% (21) · system 20.0% (10) · other 38.0% (19) tokens (other includes messages and unexposed prompts)"`);
|
|
33
|
+
});
|
|
34
|
+
test('ignores compacted history and old tool outputs', () => {
|
|
35
|
+
const messages = [
|
|
36
|
+
{ info: user('old', 'x'.repeat(800)), parts: [part('old', { type: 'text', text: 'x'.repeat(800) })] },
|
|
37
|
+
{ info: assistant('old-answer', 30), parts: [part('old-answer', { type: 'tool', tool: 'bash', callID: 'old', state: {
|
|
38
|
+
status: 'completed', input: {}, output: 'x'.repeat(800), title: 'old', metadata: {},
|
|
39
|
+
time: { start: 1, end: 2, compacted: 3 },
|
|
40
|
+
} })] },
|
|
41
|
+
{ info: user('compact'), parts: [part('compact', { type: 'compaction', auto: true })] },
|
|
42
|
+
{ info: assistant('summary', 20), parts: [part('summary', { type: 'text', text: 'summary'.repeat(8) })] },
|
|
43
|
+
{ info: user('new', 's'.repeat(20)), parts: [part('new', { type: 'text', text: 'u'.repeat(20) })] },
|
|
44
|
+
{ info: assistant('last', 40), parts: [] },
|
|
45
|
+
];
|
|
46
|
+
expect(formatContextBreakdown({ messages, lastAssistantId: 'last', inputTokens: 40 }))
|
|
47
|
+
.toMatchInlineSnapshot(`"**Estimated input mix:** system 12.5% (5) · other 87.5% (35) tokens (other includes messages and unexposed prompts)"`);
|
|
48
|
+
});
|
|
49
|
+
});
|
package/dist/commands/fork.js
CHANGED
|
@@ -5,6 +5,8 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
|
5
5
|
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, sendSessionPartBatches, } from '../discord-utils.js';
|
|
6
6
|
import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
|
+
import { copySessionPreferences } from './model.js';
|
|
9
|
+
import { copySessionSystemPrompt } from '../system-message.js';
|
|
8
10
|
import * as errore from 'errore';
|
|
9
11
|
const sessionLogger = createLogger(LogPrefix.SESSION);
|
|
10
12
|
const forkLogger = createLogger(LogPrefix.FORK);
|
|
@@ -224,7 +226,36 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
224
226
|
});
|
|
225
227
|
// Claim the forked session immediately so external polling does not race
|
|
226
228
|
// and create a duplicate Sync thread before the rest of this setup runs.
|
|
227
|
-
|
|
229
|
+
// Same agent, model and pinned system prompt keep the source prompt cache.
|
|
230
|
+
const [, , copiedSystem] = await Promise.all([
|
|
231
|
+
setThreadSession(thread.id, forkedSession.id),
|
|
232
|
+
copySessionPreferences({
|
|
233
|
+
sourceSessionId: sessionId,
|
|
234
|
+
targetSessionId: forkedSession.id,
|
|
235
|
+
channelId: textChannel.id,
|
|
236
|
+
appId: interaction.applicationId,
|
|
237
|
+
getClient,
|
|
238
|
+
directory: projectDirectory,
|
|
239
|
+
}),
|
|
240
|
+
copySessionSystemPrompt({
|
|
241
|
+
sourceSessionId: sessionId,
|
|
242
|
+
targetSessionId: forkedSession.id,
|
|
243
|
+
}),
|
|
244
|
+
]);
|
|
245
|
+
// `false` (source not pinned yet) is the only fallback; I/O errors fail setup.
|
|
246
|
+
if (copiedSystem instanceof Error) {
|
|
247
|
+
forkLogger.error(`Could not pin source system prompt on fork ${forkedSession.id}:`, copiedSystem);
|
|
248
|
+
await Promise.all([
|
|
249
|
+
thread.delete('fork setup failed').catch((error) => {
|
|
250
|
+
forkLogger.warn(`Could not delete orphan fork thread ${thread.id}:`, error);
|
|
251
|
+
}),
|
|
252
|
+
getClient().session.delete({ sessionID: forkedSession.id }).catch((error) => {
|
|
253
|
+
forkLogger.warn(`Could not delete orphan fork session ${forkedSession.id}:`, error);
|
|
254
|
+
}),
|
|
255
|
+
]);
|
|
256
|
+
await interaction.editReply(`Failed to fork session: could not copy the source system prompt (${copiedSystem.message})`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
228
259
|
// Add user to thread so it appears in their sidebar
|
|
229
260
|
await thread.members.add(interaction.user.id);
|
|
230
261
|
const sourceThreadLink = `<#${threadChannel.id}>`;
|
package/dist/commands/model.js
CHANGED
|
@@ -6,9 +6,11 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
|
6
6
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
7
7
|
import { getDefaultModel, resolveDisplayedModelId, } from '../session-handler/model-utils.js';
|
|
8
8
|
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
9
|
+
import { resolveAgentPreference } from '../session-handler/agent-utils.js';
|
|
9
10
|
import { getThinkingValuesForModel } from '../thinking-utils.js';
|
|
10
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
11
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
|
+
import { OpenCodeSdkError } from '../errors.js';
|
|
12
14
|
import * as errore from 'errore';
|
|
13
15
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
14
16
|
const modelLogger = createLogger(LogPrefix.MODEL);
|
|
@@ -123,27 +125,80 @@ export async function ensureSessionPreferencesSnapshot({ sessionId, channelId, a
|
|
|
123
125
|
});
|
|
124
126
|
modelLogger.log(`[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`);
|
|
125
127
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
const LAST_USER_AGENT_PAGE_SIZE = 50;
|
|
129
|
+
const LAST_USER_AGENT_MAX_PAGES = 10;
|
|
130
|
+
/**
|
|
131
|
+
* Agent of the newest user message in a session, paging back from the end.
|
|
132
|
+
* Long agentic turns hold many assistant messages after one user message.
|
|
133
|
+
*/
|
|
134
|
+
async function getLastUserMessageAgent({ getClient, sessionId, directory, before, page = 0, }) {
|
|
135
|
+
if (page >= LAST_USER_AGENT_MAX_PAGES)
|
|
136
|
+
return undefined;
|
|
137
|
+
const response = await (async () => {
|
|
138
|
+
return getClient().session.messages({
|
|
139
|
+
sessionID: sessionId,
|
|
140
|
+
directory,
|
|
141
|
+
limit: LAST_USER_AGENT_PAGE_SIZE,
|
|
142
|
+
before,
|
|
143
|
+
});
|
|
144
|
+
})().catch((cause) => new OpenCodeSdkError({ operation: 'session.messages', cause }));
|
|
145
|
+
if (response instanceof Error)
|
|
146
|
+
return response;
|
|
147
|
+
if (response.error || !response.data) {
|
|
148
|
+
return new OpenCodeSdkError({ operation: 'session.messages', cause: response.error });
|
|
149
|
+
}
|
|
150
|
+
const newestUser = response.data
|
|
151
|
+
.map((message) => message.info)
|
|
152
|
+
.filter((info) => info.role === 'user')
|
|
153
|
+
.sort((a, b) => b.time.created - a.time.created)[0];
|
|
154
|
+
if (newestUser?.role === 'user')
|
|
155
|
+
return newestUser.agent;
|
|
156
|
+
const cursor = response.response.headers.get('X-Next-Cursor');
|
|
157
|
+
if (!cursor)
|
|
158
|
+
return undefined;
|
|
159
|
+
return getLastUserMessageAgent({ getClient, sessionId, directory, before: cursor, page: page + 1 });
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Copy the agent, model and variant onto a forked session. The fork must run
|
|
163
|
+
* the same agent and model as the source: a different agent changes tools and
|
|
164
|
+
* prompts, which busts the prompt cache. The agent comes from the fork's own
|
|
165
|
+
* history (the fork point, also right for /fork at an earlier message), with
|
|
166
|
+
* the source DB preference as fallback.
|
|
167
|
+
*/
|
|
168
|
+
export async function copySessionPreferences({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
|
|
169
|
+
const [historyAgent, preferredAgent, modelInfo, variant] = await Promise.all([
|
|
170
|
+
getClient instanceof Error
|
|
171
|
+
? undefined
|
|
172
|
+
: getLastUserMessageAgent({ getClient, sessionId: targetSessionId, directory }),
|
|
173
|
+
resolveAgentPreference({ sessionId: sourceSessionId, channelId }),
|
|
174
|
+
getCurrentModelInfo({
|
|
175
|
+
sessionId: sourceSessionId,
|
|
176
|
+
channelId,
|
|
177
|
+
appId,
|
|
178
|
+
getClient,
|
|
179
|
+
directory,
|
|
180
|
+
}),
|
|
181
|
+
getVariantCascade({
|
|
182
|
+
sessionId: sourceSessionId,
|
|
183
|
+
channelId,
|
|
184
|
+
appId,
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
if (historyAgent instanceof Error) {
|
|
188
|
+
modelLogger.warn(`[MODEL] Could not read fork history agent for ${targetSessionId}, using DB preference: ${historyAgent.message}`);
|
|
189
|
+
}
|
|
190
|
+
const agent = (historyAgent instanceof Error ? undefined : historyAgent) || preferredAgent;
|
|
191
|
+
if (agent) {
|
|
192
|
+
await setSessionAgent(targetSessionId, agent);
|
|
193
|
+
}
|
|
134
194
|
if (modelInfo.type === 'none')
|
|
135
195
|
return;
|
|
136
|
-
const variant = await getVariantCascade({
|
|
137
|
-
sessionId: sourceSessionId,
|
|
138
|
-
channelId,
|
|
139
|
-
appId,
|
|
140
|
-
});
|
|
141
196
|
await setSessionModel({
|
|
142
197
|
sessionId: targetSessionId,
|
|
143
198
|
modelId: modelInfo.model,
|
|
144
199
|
variant: variant ?? null,
|
|
145
200
|
});
|
|
146
|
-
modelLogger.log(`[MODEL] Copied session model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
|
|
201
|
+
modelLogger.log(`[MODEL] Copied session agent ${agent ?? 'default'} and model ${modelInfo.model} from ${sourceSessionId} to ${targetSessionId}`);
|
|
147
202
|
}
|
|
148
203
|
/**
|
|
149
204
|
* Get the current model info for a channel/session, including where it comes from.
|
|
@@ -15,7 +15,7 @@ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js
|
|
|
15
15
|
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
16
16
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
17
17
|
import * as errore from 'errore';
|
|
18
|
-
import {
|
|
18
|
+
import { copySessionPreferences } from './model.js';
|
|
19
19
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
20
20
|
async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
|
|
21
21
|
if (!rawBaseBranch) {
|
|
@@ -551,7 +551,10 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
|
|
|
551
551
|
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
|
|
552
552
|
return;
|
|
553
553
|
}
|
|
554
|
-
|
|
554
|
+
// No system prompt copy: OpenCode's env block (cwd) precedes the kimaki
|
|
555
|
+
// system prompt, so a fork into another directory never shares the
|
|
556
|
+
// source cache prefix. The fork pins its own prompt on its first turn.
|
|
557
|
+
await copySessionPreferences({
|
|
555
558
|
sourceSessionId,
|
|
556
559
|
targetSessionId: forkedSession.id,
|
|
557
560
|
channelId: parent.id,
|
package/dist/commands/queue.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils
|
|
|
6
6
|
import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
|
|
9
|
+
import { extractBtwQueueSuffix } from '../btw-prefix-detection.js';
|
|
9
10
|
import { store } from '../store.js';
|
|
10
11
|
const logger = createLogger(LogPrefix.QUEUE);
|
|
11
12
|
export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
|
|
@@ -52,6 +53,19 @@ function buildQueueRemoveRow({ threadId, queueId, }) {
|
|
|
52
53
|
.setLabel('Remove from queue')
|
|
53
54
|
.setStyle(ButtonStyle.Secondary));
|
|
54
55
|
}
|
|
56
|
+
// Replies "Queued message" and returns the ack id, so the drain indicator can
|
|
57
|
+
// reply to it. The runtime calls this inside the serialized enqueue.
|
|
58
|
+
function replyQueuedAck({ command, threadId, }) {
|
|
59
|
+
return async ({ queueId, position }) => {
|
|
60
|
+
await command.reply({
|
|
61
|
+
content: asSubtext(`Queued message (position ${position})`),
|
|
62
|
+
components: [buildQueueRemoveRow({ threadId, queueId })],
|
|
63
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
64
|
+
});
|
|
65
|
+
const ack = await command.fetchReply();
|
|
66
|
+
return ack.id;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
55
69
|
export async function handleQueueRemoveButton(interaction) {
|
|
56
70
|
const parsed = parseQueueRemoveCustomId(interaction.customId);
|
|
57
71
|
if (!parsed) {
|
|
@@ -90,8 +104,18 @@ export async function handleQueueRemoveButton(interaction) {
|
|
|
90
104
|
logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
|
|
91
105
|
}
|
|
92
106
|
export async function handleQueueCommand({ command, appId, }) {
|
|
93
|
-
|
|
107
|
+
// /queue always queues; a typed suffix is redundant, so only strip it.
|
|
108
|
+
const rawMessage = command.options.getString('message', true);
|
|
109
|
+
const btw = extractBtwQueueSuffix(rawMessage);
|
|
110
|
+
const message = btw.prompt;
|
|
94
111
|
const channel = command.channel;
|
|
112
|
+
if (!message.trim()) {
|
|
113
|
+
await command.reply({
|
|
114
|
+
content: 'Write a message to queue',
|
|
115
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
95
119
|
if (!channel) {
|
|
96
120
|
await command.reply({
|
|
97
121
|
content: 'This command can only be used in a channel',
|
|
@@ -139,23 +163,15 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
139
163
|
// /queue explicitly uses kimaki local queue mode.
|
|
140
164
|
const enqueueResult = await runtime.enqueueIncoming({
|
|
141
165
|
prompt: message,
|
|
166
|
+
queuedAction: btw.forceBtw ? 'btw' : undefined,
|
|
142
167
|
userId: command.user.id,
|
|
143
168
|
username: command.user.displayName,
|
|
144
169
|
appId,
|
|
145
170
|
mode: 'local-queue',
|
|
171
|
+
onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
|
|
146
172
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await command.reply({
|
|
150
|
-
content: responseText,
|
|
151
|
-
components: [
|
|
152
|
-
buildQueueRemoveRow({
|
|
153
|
-
threadId: thread.id,
|
|
154
|
-
queueId: enqueueResult.queueId,
|
|
155
|
-
}),
|
|
156
|
-
],
|
|
157
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
158
|
-
});
|
|
173
|
+
// onLocalQueued already posted the ack.
|
|
174
|
+
if (enqueueResult.queued) {
|
|
159
175
|
return;
|
|
160
176
|
}
|
|
161
177
|
const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`;
|
|
@@ -297,19 +313,10 @@ export async function handleQueueCommandCommand({ command, appId, }) {
|
|
|
297
313
|
appId,
|
|
298
314
|
command: commandPayload,
|
|
299
315
|
mode: 'local-queue',
|
|
316
|
+
onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
|
|
300
317
|
});
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
await command.reply({
|
|
304
|
-
content: responseText,
|
|
305
|
-
components: [
|
|
306
|
-
buildQueueRemoveRow({
|
|
307
|
-
threadId: thread.id,
|
|
308
|
-
queueId: enqueueResult.queueId,
|
|
309
|
-
}),
|
|
310
|
-
],
|
|
311
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
312
|
-
});
|
|
318
|
+
// onLocalQueued already posted the ack.
|
|
319
|
+
if (enqueueResult.queued) {
|
|
313
320
|
logger.log(`[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`);
|
|
314
321
|
return;
|
|
315
322
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sendThreadMessage, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, resol
|
|
|
12
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
13
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
14
14
|
import { DiscordOperationError } from '../errors.js';
|
|
15
|
+
import { extractQueueSuffix } from '../message-formatting.js';
|
|
15
16
|
const logger = createLogger(LogPrefix.SESSION);
|
|
16
17
|
// Create only: callers confirm the destination before dispatching the full prompt.
|
|
17
18
|
export async function createNewSessionThread({ textChannel, projectDirectory, sourceWorkspace, prompt, files = [], userId, }) {
|
|
@@ -54,10 +55,14 @@ export async function createNewSessionThread({ textChannel, projectDirectory, so
|
|
|
54
55
|
}
|
|
55
56
|
export async function handleSessionCommand({ command, appId, }) {
|
|
56
57
|
await command.deferReply();
|
|
57
|
-
const prompt = command.options.getString('prompt', true);
|
|
58
|
+
const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
|
|
58
59
|
const filesString = command.options.getString('files') || '';
|
|
59
60
|
const agent = command.options.getString('agent') || undefined;
|
|
60
61
|
const channel = command.channel;
|
|
62
|
+
if (!prompt.trim()) {
|
|
63
|
+
await command.editReply('Write a prompt for the new session');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
61
66
|
const isThread = channel && [
|
|
62
67
|
ChannelType.PublicThread,
|
|
63
68
|
ChannelType.PrivateThread,
|
|
@@ -139,7 +144,7 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
139
144
|
username: command.user.displayName,
|
|
140
145
|
agent,
|
|
141
146
|
appId,
|
|
142
|
-
mode: 'opencode',
|
|
147
|
+
mode: forceQueue ? 'local-queue' : 'opencode',
|
|
143
148
|
}).catch(async (error) => {
|
|
144
149
|
logger.error('New session dispatch failed:', error);
|
|
145
150
|
await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
|
package/dist/db.test.js
CHANGED
|
@@ -7,11 +7,11 @@ 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, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionAgent, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionAgent, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
11
11
|
import { createClient } from '@libsql/client';
|
|
12
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
13
13
|
import { chooseLockPort } from './test-utils.js';
|
|
14
|
-
import {
|
|
14
|
+
import { copySessionPreferences } from './commands/model.js';
|
|
15
15
|
afterAll(async () => {
|
|
16
16
|
await closeDb();
|
|
17
17
|
});
|
|
@@ -566,7 +566,9 @@ describe('getDb', () => {
|
|
|
566
566
|
await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
567
567
|
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
568
568
|
});
|
|
569
|
-
|
|
569
|
+
// Regression: btw forks lost the agent and ran `build` instead of the source
|
|
570
|
+
// agent, which changed tools and busted the whole prompt cache.
|
|
571
|
+
test('copySessionPreferences snapshots source session agent and model to forked session', async () => {
|
|
570
572
|
const db = await getDb();
|
|
571
573
|
const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
|
|
572
574
|
const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
|
|
@@ -578,18 +580,26 @@ describe('getDb', () => {
|
|
|
578
580
|
modelId: 'anthropic/claude-opus-4-6',
|
|
579
581
|
variant: 'thinking',
|
|
580
582
|
});
|
|
581
|
-
await
|
|
583
|
+
await setSessionAgent(sourceSessionId, 'opus');
|
|
584
|
+
await copySessionPreferences({
|
|
582
585
|
sourceSessionId,
|
|
583
586
|
targetSessionId,
|
|
584
587
|
getClient,
|
|
585
588
|
});
|
|
586
|
-
|
|
589
|
+
expect({
|
|
590
|
+
agent: await getSessionAgent(targetSessionId),
|
|
591
|
+
model: await getSessionModel(targetSessionId),
|
|
592
|
+
}).toMatchInlineSnapshot(`
|
|
587
593
|
{
|
|
588
|
-
"
|
|
589
|
-
"
|
|
594
|
+
"agent": "opus",
|
|
595
|
+
"model": {
|
|
596
|
+
"modelId": "anthropic/claude-opus-4-6",
|
|
597
|
+
"variant": "thinking",
|
|
598
|
+
},
|
|
590
599
|
}
|
|
591
600
|
`);
|
|
592
601
|
await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
|
|
602
|
+
await db.delete(schema.session_agents).where(orm.inArray(schema.session_agents.session_id, [sourceSessionId, targetSessionId]));
|
|
593
603
|
});
|
|
594
604
|
test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
|
|
595
605
|
const db = await getDb();
|