kimaki 0.30.0 → 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 +5 -11
- 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/abort.js +16 -4
- 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 +37 -30
- package/dist/commands/session.js +7 -2
- package/dist/db.test.js +17 -7
- package/dist/discord-bot.js +70 -47
- package/dist/discord-utils.js +15 -1
- package/dist/errors.js +10 -5
- package/dist/external-directory-permissions.e2e.test.js +4 -4
- package/dist/external-directory-project-deny.e2e.test.js +2 -2
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/message-formatting.js +50 -15
- package/dist/message-formatting.test.js +12 -11
- package/dist/message-preprocessing.js +53 -75
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +62 -6
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-footer.e2e.test.js +8 -8
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- 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 +24 -16
- package/dist/session-handler/event-stream-state.test.js +281 -14
- package/dist/session-handler/thread-session-runtime.js +209 -128
- package/dist/sleep.e2e.test.js +6 -6
- package/dist/system-message.js +214 -392
- package/dist/system-message.test.js +204 -380
- package/dist/task-runner.js +3 -3
- package/dist/task-schedule.js +2 -2
- package/dist/test-utils.js +32 -6
- package/dist/thread-message-queue.e2e.test.js +132 -60
- package/dist/undo-redo.e2e.test.js +3 -3
- 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 +6 -9
- package/skills/critique/SKILL.md +4 -0
- package/skills/holocron/SKILL.md +0 -28
- 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 +11 -11
- 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/abort.ts +20 -4
- 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 +46 -30
- package/src/commands/session.ts +10 -2
- package/src/db.test.ts +18 -6
- package/src/discord-bot.ts +72 -56
- package/src/discord-utils.ts +17 -1
- package/src/errors.ts +12 -6
- package/src/external-directory-permissions.e2e.test.ts +4 -4
- package/src/external-directory-project-deny.e2e.test.ts +2 -2
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/message-formatting.test.ts +12 -11
- package/src/message-formatting.ts +52 -16
- package/src/message-preprocessing.ts +69 -91
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +73 -6
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-footer.e2e.test.ts +8 -8
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- 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 +284 -13
- package/src/session-handler/event-stream-state.ts +37 -21
- package/src/session-handler/thread-runtime-state.ts +7 -0
- package/src/session-handler/thread-session-runtime.ts +255 -155
- package/src/sleep.e2e.test.ts +6 -6
- package/src/system-message.test.ts +216 -379
- package/src/system-message.ts +253 -389
- package/src/task-runner.ts +3 -3
- package/src/task-schedule.ts +2 -2
- package/src/test-utils.ts +55 -12
- package/src/thread-message-queue.e2e.test.ts +136 -63
- package/src/undo-redo.e2e.test.ts +3 -3
- 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
package/dist/commands/btw.js
CHANGED
|
@@ -8,9 +8,19 @@ 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 {
|
|
11
|
+
import { copySessionPreferences } from './model.js';
|
|
12
|
+
import { copySessionSystemPrompt } from '../system-message.js';
|
|
13
|
+
import { OpenCodeSdkError } from '../errors.js';
|
|
14
|
+
import { extractQueueSuffix } from '../message-formatting.js';
|
|
12
15
|
const logger = createLogger(LogPrefix.FORK);
|
|
16
|
+
// Resolves to [value, elapsed ms] so each btw setup step can be logged.
|
|
17
|
+
async function timed(promise) {
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
const value = await promise;
|
|
20
|
+
return [value, Date.now() - start];
|
|
21
|
+
}
|
|
13
22
|
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, modelPrompt = prompt, userId, username, appId, agent, images, }) {
|
|
23
|
+
const startedAt = Date.now();
|
|
14
24
|
// Parallelize: session lookup + opencode init + parent channel resolve are independent
|
|
15
25
|
const [sessionId, getClientResult, textChannel] = await Promise.all([
|
|
16
26
|
getThreadSession(sourceThread.id),
|
|
@@ -28,51 +38,106 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
|
|
|
28
38
|
if (!textChannel) {
|
|
29
39
|
return new Error('Could not resolve parent text channel');
|
|
30
40
|
}
|
|
31
|
-
// Fork
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
// Fork and thread creation are independent round trips, so run them together.
|
|
42
|
+
// If either side fails, remove whichever side succeeded.
|
|
43
|
+
const initMs = Date.now() - startedAt;
|
|
44
|
+
const [forkSettled, threadSettled] = await Promise.allSettled([
|
|
45
|
+
timed(getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory })),
|
|
46
|
+
timed(textChannel.threads.create({
|
|
47
|
+
name: `btw: ${prompt}`.slice(0, 100),
|
|
48
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
49
|
+
reason: `btw fork from session ${sessionId}`,
|
|
50
|
+
})),
|
|
51
|
+
]);
|
|
52
|
+
const forkedSession = forkSettled.status === 'fulfilled' ? forkSettled.value[0].data : undefined;
|
|
53
|
+
const createdThread = threadSettled.status === 'fulfilled' ? threadSettled.value[0] : undefined;
|
|
54
|
+
const cleanup = async () => {
|
|
55
|
+
await Promise.all([
|
|
56
|
+
createdThread?.delete('btw fork setup failed').catch((error) => {
|
|
57
|
+
logger.warn(`Could not delete orphan btw thread ${createdThread.id}:`, error);
|
|
58
|
+
}),
|
|
59
|
+
forkedSession && getClientResult()
|
|
60
|
+
.session.delete({ sessionID: forkedSession.id, directory: sdkDirectory })
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
logger.warn(`Could not delete orphan btw session ${forkedSession.id}:`, error);
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
};
|
|
66
|
+
if (!forkedSession) {
|
|
67
|
+
await cleanup();
|
|
68
|
+
const cause = forkSettled.status === 'rejected' ? forkSettled.reason : forkSettled.value[0].error;
|
|
69
|
+
return new OpenCodeSdkError({ operation: 'session.fork', cause });
|
|
35
70
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
targetSessionId: forkedSession.id,
|
|
41
|
-
channelId,
|
|
42
|
-
appId,
|
|
43
|
-
getClient: getClientResult,
|
|
44
|
-
directory: sdkDirectory,
|
|
45
|
-
});
|
|
46
|
-
const thread = await textChannel.threads.create({
|
|
47
|
-
name: `btw: ${prompt}`.slice(0, 100),
|
|
48
|
-
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
49
|
-
reason: `btw fork from session ${sessionId}`,
|
|
50
|
-
});
|
|
51
|
-
// DB mapping must complete before user-visible actions so the thread is routable
|
|
52
|
-
await setThreadSession(thread.id, forkedSession.id);
|
|
53
|
-
const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id);
|
|
54
|
-
if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
|
|
55
|
-
await createPendingWorkspace({
|
|
56
|
-
threadId: thread.id,
|
|
57
|
-
workspaceType: sourceWorkspace.workspace_type,
|
|
58
|
-
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
59
|
-
projectDirectory,
|
|
60
|
-
});
|
|
61
|
-
await setWorkspaceReady({
|
|
62
|
-
threadId: thread.id,
|
|
63
|
-
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
64
|
-
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
71
|
+
if (!createdThread) {
|
|
72
|
+
await cleanup();
|
|
73
|
+
return new Error('Failed to create the btw thread', {
|
|
74
|
+
cause: threadSettled.status === 'rejected' ? threadSettled.reason : undefined,
|
|
65
75
|
});
|
|
66
76
|
}
|
|
67
|
-
|
|
77
|
+
const thread = createdThread;
|
|
78
|
+
const forkMs = forkSettled.status === 'fulfilled' ? forkSettled.value[1] : -1;
|
|
79
|
+
const threadMs = threadSettled.status === 'fulfilled' ? threadSettled.value[1] : -1;
|
|
80
|
+
const channelId = sourceThread.parentId || sourceThread.id;
|
|
68
81
|
const sourceThreadLink = `<#${sourceThread.id}>`;
|
|
82
|
+
// The fork must run with the source agent, model and pinned system prompt,
|
|
83
|
+
// so its request prefix is byte-identical and hits the source prompt cache.
|
|
84
|
+
// `false` (source not pinned yet) is the only fallback; I/O errors fail setup.
|
|
85
|
+
const copyStartedAt = Date.now();
|
|
86
|
+
const [, copiedSystem, history] = await Promise.all([
|
|
87
|
+
copySessionPreferences({
|
|
88
|
+
sourceSessionId: sessionId,
|
|
89
|
+
targetSessionId: forkedSession.id,
|
|
90
|
+
channelId,
|
|
91
|
+
appId,
|
|
92
|
+
getClient: getClientResult,
|
|
93
|
+
directory: sdkDirectory,
|
|
94
|
+
}),
|
|
95
|
+
copySessionSystemPrompt({
|
|
96
|
+
sourceSessionId: sessionId,
|
|
97
|
+
targetSessionId: forkedSession.id,
|
|
98
|
+
}),
|
|
99
|
+
getClientResult().session.messages({
|
|
100
|
+
sessionID: forkedSession.id,
|
|
101
|
+
directory: sdkDirectory,
|
|
102
|
+
limit: 10,
|
|
103
|
+
}).catch((cause) => new OpenCodeSdkError({ operation: 'session.messages', cause })),
|
|
104
|
+
]);
|
|
105
|
+
if (copiedSystem instanceof Error) {
|
|
106
|
+
await cleanup();
|
|
107
|
+
return new Error(`Could not copy the source system prompt to the fork: ${copiedSystem.message}`, {
|
|
108
|
+
cause: copiedSystem,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const copyMs = Date.now() - copyStartedAt;
|
|
112
|
+
const routeStartedAt = Date.now();
|
|
69
113
|
await Promise.all([
|
|
114
|
+
// DB mapping must complete before dispatch so the thread is routable
|
|
115
|
+
(async () => {
|
|
116
|
+
await setThreadSession(thread.id, forkedSession.id);
|
|
117
|
+
const sourceWorkspace = await getThreadWorktreeOrWorkspace(sourceThread.id);
|
|
118
|
+
if (sourceWorkspace?.status !== 'ready' || !sourceWorkspace.workspace_directory) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await createPendingWorkspace({
|
|
122
|
+
threadId: thread.id,
|
|
123
|
+
workspaceType: sourceWorkspace.workspace_type,
|
|
124
|
+
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
125
|
+
projectDirectory,
|
|
126
|
+
});
|
|
127
|
+
await setWorkspaceReady({
|
|
128
|
+
threadId: thread.id,
|
|
129
|
+
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
130
|
+
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
131
|
+
});
|
|
132
|
+
})(),
|
|
70
133
|
thread.members.add(userId).catch((error) => {
|
|
71
134
|
logger.warn('Could not add fork member:', error);
|
|
72
135
|
}),
|
|
73
136
|
sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
|
|
74
137
|
]);
|
|
75
|
-
|
|
138
|
+
const routeMs = Date.now() - routeStartedAt;
|
|
139
|
+
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId}), system prompt ${copiedSystem ? 'reused' : 'regenerated'}`);
|
|
140
|
+
logger.log(`[BTW TIMING] ${forkedSession.id} init=${initMs}ms fork=${forkMs}ms threadCreate=${threadMs}ms copyPrefs=${copyMs}ms route=${routeMs}ms total=${Date.now() - startedAt}ms`);
|
|
76
141
|
// Parent context stays in the user prompt only. Do NOT pass parentSessionId
|
|
77
142
|
// into enqueueIncoming: that would inject a parent block into the system
|
|
78
143
|
// message and bust prompt cache shared with the parent session.
|
|
@@ -95,7 +160,18 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
|
|
|
95
160
|
appId,
|
|
96
161
|
sessionId: forkedSession.id,
|
|
97
162
|
});
|
|
98
|
-
|
|
163
|
+
if (history instanceof Error || history.error) {
|
|
164
|
+
logger.warn('Could not load copied messages for btw cache diagnostics:', history);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const last = history.data?.findLast(({ info }) => info.role === 'assistant' && typeof info.time.completed === 'number')?.info;
|
|
168
|
+
if (last?.role === 'assistant') {
|
|
169
|
+
runtime.seedForkPromptCacheBaseline(last);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Not awaited: the caller confirms in the source thread right away while the
|
|
173
|
+
// runtime resolves preferences and dispatches. Failures are reported in the fork.
|
|
174
|
+
void runtime.enqueueIncoming({
|
|
99
175
|
prompt: wrappedPrompt,
|
|
100
176
|
agent,
|
|
101
177
|
images,
|
|
@@ -103,7 +179,9 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, s
|
|
|
103
179
|
username,
|
|
104
180
|
appId,
|
|
105
181
|
mode: 'opencode',
|
|
106
|
-
}).
|
|
182
|
+
}).then(() => {
|
|
183
|
+
logger.log(`[BTW TIMING] ${forkedSession.id} promptAccepted=${Date.now() - startedAt}ms`);
|
|
184
|
+
}, async (error) => {
|
|
107
185
|
logger.error('Fork dispatch failed:', error);
|
|
108
186
|
await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
|
|
109
187
|
});
|
|
@@ -131,7 +209,11 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
131
209
|
return;
|
|
132
210
|
}
|
|
133
211
|
const threadChannel = channel;
|
|
134
|
-
const prompt = command.options.getString('prompt', true);
|
|
212
|
+
const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
|
|
213
|
+
if (!prompt.trim()) {
|
|
214
|
+
await command.reply({ content: 'Write a question to fork', flags: MessageFlags.Ephemeral });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
135
217
|
const resolved = await resolveWorkingDirectory({
|
|
136
218
|
channel: threadChannel,
|
|
137
219
|
});
|
|
@@ -145,6 +227,32 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
145
227
|
const { projectDirectory, workingDirectory } = resolved;
|
|
146
228
|
await command.deferReply({ flags: MessageFlags.Ephemeral });
|
|
147
229
|
try {
|
|
230
|
+
if (forceQueue) {
|
|
231
|
+
if (!await getThreadSession(threadChannel.id)) {
|
|
232
|
+
await command.editReply('No active session in this thread. Start a session, then retry /btw.');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const runtime = getOrCreateRuntime({
|
|
236
|
+
threadId: threadChannel.id,
|
|
237
|
+
thread: threadChannel,
|
|
238
|
+
projectDirectory,
|
|
239
|
+
sdkDirectory: workingDirectory,
|
|
240
|
+
channelId: threadChannel.parentId || threadChannel.id,
|
|
241
|
+
appId,
|
|
242
|
+
});
|
|
243
|
+
const queued = await runtime.enqueueIncoming({
|
|
244
|
+
prompt,
|
|
245
|
+
queuedAction: 'btw',
|
|
246
|
+
userId: command.user.id,
|
|
247
|
+
username: command.user.displayName,
|
|
248
|
+
appId,
|
|
249
|
+
mode: 'local-queue',
|
|
250
|
+
});
|
|
251
|
+
await command.editReply(queued.queued
|
|
252
|
+
? `Btw fork queued at position ${queued.position}. It will start after the earlier prompts finish.`
|
|
253
|
+
: 'Btw fork started. The new thread will appear shortly.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
148
256
|
const result = await forkSessionToBtwThread({
|
|
149
257
|
sourceThread: threadChannel,
|
|
150
258
|
projectDirectory,
|
|
@@ -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,
|