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.
Files changed (124) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +5 -11
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/abort.js +16 -4
  10. package/dist/commands/btw.js +147 -39
  11. package/dist/commands/context-usage.js +47 -0
  12. package/dist/commands/context-usage.test.js +49 -0
  13. package/dist/commands/fork.js +32 -1
  14. package/dist/commands/model.js +69 -14
  15. package/dist/commands/new-worktree.js +5 -2
  16. package/dist/commands/queue.js +37 -30
  17. package/dist/commands/session.js +7 -2
  18. package/dist/db.test.js +17 -7
  19. package/dist/discord-bot.js +70 -47
  20. package/dist/discord-utils.js +15 -1
  21. package/dist/errors.js +10 -5
  22. package/dist/external-directory-permissions.e2e.test.js +4 -4
  23. package/dist/external-directory-project-deny.e2e.test.js +2 -2
  24. package/dist/gateway-proxy.e2e.test.js +5 -5
  25. package/dist/message-formatting.js +50 -15
  26. package/dist/message-formatting.test.js +12 -11
  27. package/dist/message-preprocessing.js +53 -75
  28. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  29. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  30. package/dist/queue-advanced-abort.e2e.test.js +62 -6
  31. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  32. package/dist/queue-advanced-footer.e2e.test.js +8 -8
  33. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  35. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  36. package/dist/queue-delete-message.e2e.test.js +146 -5
  37. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  38. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  39. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  40. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  41. package/dist/session-handler/agent-utils.js +16 -17
  42. package/dist/session-handler/event-stream-state.js +24 -16
  43. package/dist/session-handler/event-stream-state.test.js +281 -14
  44. package/dist/session-handler/thread-session-runtime.js +209 -128
  45. package/dist/sleep.e2e.test.js +6 -6
  46. package/dist/system-message.js +214 -392
  47. package/dist/system-message.test.js +204 -380
  48. package/dist/task-runner.js +3 -3
  49. package/dist/task-schedule.js +2 -2
  50. package/dist/test-utils.js +32 -6
  51. package/dist/thread-message-queue.e2e.test.js +132 -60
  52. package/dist/undo-redo.e2e.test.js +3 -3
  53. package/dist/voice-handler.js +2 -1
  54. package/dist/voice-message.e2e.test.js +22 -31
  55. package/dist/voice-routing-input.e2e.test.js +2 -2
  56. package/dist/voice-routing-question.e2e.test.js +59 -2
  57. package/dist/voice.js +306 -206
  58. package/dist/voice.test.js +161 -13
  59. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  60. package/package.json +6 -9
  61. package/skills/critique/SKILL.md +4 -0
  62. package/skills/holocron/SKILL.md +0 -28
  63. package/skills/opensrc/SKILL.md +20 -13
  64. package/skills/playwriter/SKILL.md +1 -1
  65. package/src/agent-model.e2e.test.ts +28 -19
  66. package/src/analytics.ts +3 -1
  67. package/src/btw-prefix-detection.test.ts +39 -1
  68. package/src/btw-prefix-detection.ts +11 -0
  69. package/src/cli-commands/send.ts +11 -11
  70. package/src/cli-runner.test.ts +37 -0
  71. package/src/cli-runner.ts +26 -10
  72. package/src/cli-send-thread.e2e.test.ts +1 -1
  73. package/src/commands/abort.ts +20 -4
  74. package/src/commands/btw.ts +151 -41
  75. package/src/commands/context-usage.test.ts +57 -0
  76. package/src/commands/context-usage.ts +59 -0
  77. package/src/commands/fork.ts +34 -1
  78. package/src/commands/model.ts +81 -14
  79. package/src/commands/new-worktree.ts +5 -2
  80. package/src/commands/queue.ts +46 -30
  81. package/src/commands/session.ts +10 -2
  82. package/src/db.test.ts +18 -6
  83. package/src/discord-bot.ts +72 -56
  84. package/src/discord-utils.ts +17 -1
  85. package/src/errors.ts +12 -6
  86. package/src/external-directory-permissions.e2e.test.ts +4 -4
  87. package/src/external-directory-project-deny.e2e.test.ts +2 -2
  88. package/src/gateway-proxy.e2e.test.ts +5 -5
  89. package/src/message-formatting.test.ts +12 -11
  90. package/src/message-formatting.ts +52 -16
  91. package/src/message-preprocessing.ts +69 -91
  92. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  93. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  94. package/src/queue-advanced-abort.e2e.test.ts +73 -6
  95. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  96. package/src/queue-advanced-footer.e2e.test.ts +8 -8
  97. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  98. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  99. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  100. package/src/queue-delete-message.e2e.test.ts +164 -4
  101. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  102. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  103. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  104. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  105. package/src/session-handler/agent-utils.ts +25 -20
  106. package/src/session-handler/event-stream-state.test.ts +284 -13
  107. package/src/session-handler/event-stream-state.ts +37 -21
  108. package/src/session-handler/thread-runtime-state.ts +7 -0
  109. package/src/session-handler/thread-session-runtime.ts +255 -155
  110. package/src/sleep.e2e.test.ts +6 -6
  111. package/src/system-message.test.ts +216 -379
  112. package/src/system-message.ts +253 -389
  113. package/src/task-runner.ts +3 -3
  114. package/src/task-schedule.ts +2 -2
  115. package/src/test-utils.ts +55 -12
  116. package/src/thread-message-queue.e2e.test.ts +136 -63
  117. package/src/undo-redo.e2e.test.ts +3 -3
  118. package/src/voice-handler.ts +2 -1
  119. package/src/voice-message.e2e.test.ts +22 -33
  120. package/src/voice-routing-input.e2e.test.ts +2 -2
  121. package/src/voice-routing-question.e2e.test.ts +59 -2
  122. package/src/voice.test.ts +164 -14
  123. package/src/voice.ts +400 -279
  124. package/src/worktree-lifecycle.e2e.test.ts +7 -7
@@ -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 { copyCurrentSessionModel } from './model.js';
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 must succeed before creating the Discord thread to avoid orphan threads
32
- const forkResponse = await getClientResult().session.fork({ sessionID: sessionId, directory: sdkDirectory });
33
- if (!forkResponse.data) {
34
- return new Error('Failed to fork session');
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
- 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: 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
- // Parallelize: member add and status message are independent best-effort actions
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
- logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
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
- await runtime.enqueueIncoming({
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
- }).catch(async (error) => {
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
+ });
@@ -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
- await setThreadSession(thread.id, forkedSession.id);
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}>`;
@@ -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
- export async function copyCurrentSessionModel({ sourceSessionId, targetSessionId, channelId, appId, getClient, directory, }) {
127
- const modelInfo = await getCurrentModelInfo({
128
- sessionId: sourceSessionId,
129
- channelId,
130
- appId,
131
- getClient,
132
- directory,
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 { copyCurrentSessionModel } from './model.js';
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
- await copyCurrentSessionModel({
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,