kimaki 0.4.90 → 0.4.91

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 (114) hide show
  1. package/dist/agent-model.e2e.test.js +80 -2
  2. package/dist/anthropic-auth-plugin.js +246 -195
  3. package/dist/anthropic-auth-plugin.test.js +125 -0
  4. package/dist/anthropic-auth-state.js +231 -0
  5. package/dist/bin.js +6 -3
  6. package/dist/cli-parsing.test.js +23 -0
  7. package/dist/cli-send-thread.e2e.test.js +2 -2
  8. package/dist/cli.js +72 -46
  9. package/dist/commands/merge-worktree.js +6 -3
  10. package/dist/commands/new-worktree.js +18 -7
  11. package/dist/commands/worktrees.js +71 -7
  12. package/dist/context-awareness-plugin.js +52 -50
  13. package/dist/context-awareness-plugin.test.js +68 -1
  14. package/dist/discord-bot.js +126 -54
  15. package/dist/discord-utils.test.js +19 -0
  16. package/dist/errors.js +0 -5
  17. package/dist/exec-async.js +26 -0
  18. package/dist/external-opencode-sync.js +33 -72
  19. package/dist/forum-sync/config.js +2 -2
  20. package/dist/forum-sync/markdown.js +4 -8
  21. package/dist/hrana-server.js +11 -3
  22. package/dist/image-optimizer-plugin.js +153 -0
  23. package/dist/ipc-tools-plugin.js +11 -4
  24. package/dist/kimaki-opencode-plugin.js +1 -0
  25. package/dist/logger.js +0 -1
  26. package/dist/markdown.js +2 -2
  27. package/dist/message-preprocessing.js +100 -16
  28. package/dist/onboarding-tutorial.js +1 -1
  29. package/dist/opencode-command-detection.js +70 -0
  30. package/dist/opencode-command-detection.test.js +210 -0
  31. package/dist/opencode-interrupt-plugin.js +64 -8
  32. package/dist/opencode-interrupt-plugin.test.js +23 -39
  33. package/dist/opencode.js +16 -20
  34. package/dist/pkce.js +23 -0
  35. package/dist/plugin-logger.js +59 -0
  36. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  37. package/dist/queue-advanced-question.e2e.test.js +127 -42
  38. package/dist/sentry.js +7 -114
  39. package/dist/session-handler/event-stream-state.js +1 -1
  40. package/dist/session-handler/thread-runtime-state.js +9 -0
  41. package/dist/session-handler/thread-session-runtime.js +197 -45
  42. package/dist/session-title-rename.test.js +80 -0
  43. package/dist/store.js +1 -2
  44. package/dist/system-message.js +105 -49
  45. package/dist/system-message.test.js +598 -15
  46. package/dist/task-runner.js +7 -4
  47. package/dist/task-schedule.js +2 -0
  48. package/dist/thread-message-queue.e2e.test.js +18 -11
  49. package/dist/unnest-code-blocks.js +11 -1
  50. package/dist/unnest-code-blocks.test.js +32 -0
  51. package/dist/voice-handler.js +15 -5
  52. package/dist/voice.js +53 -23
  53. package/dist/voice.test.js +2 -0
  54. package/dist/worktrees.js +111 -120
  55. package/package.json +15 -19
  56. package/skills/lintcn/SKILL.md +6 -1
  57. package/skills/new-skill/SKILL.md +211 -0
  58. package/skills/npm-package/SKILL.md +3 -2
  59. package/skills/spiceflow/SKILL.md +1 -1
  60. package/skills/usecomputer/SKILL.md +174 -249
  61. package/src/agent-model.e2e.test.ts +95 -2
  62. package/src/anthropic-auth-plugin.test.ts +159 -0
  63. package/src/anthropic-auth-plugin.ts +474 -403
  64. package/src/anthropic-auth-state.ts +282 -0
  65. package/src/bin.ts +6 -3
  66. package/src/cli-parsing.test.ts +32 -0
  67. package/src/cli-send-thread.e2e.test.ts +2 -2
  68. package/src/cli.ts +93 -62
  69. package/src/commands/merge-worktree.ts +8 -3
  70. package/src/commands/new-worktree.ts +22 -10
  71. package/src/commands/worktrees.ts +86 -5
  72. package/src/context-awareness-plugin.test.ts +77 -1
  73. package/src/context-awareness-plugin.ts +85 -64
  74. package/src/discord-bot.ts +135 -56
  75. package/src/discord-utils.test.ts +21 -0
  76. package/src/errors.ts +0 -6
  77. package/src/exec-async.ts +35 -0
  78. package/src/external-opencode-sync.ts +39 -85
  79. package/src/forum-sync/config.ts +2 -2
  80. package/src/forum-sync/markdown.ts +5 -9
  81. package/src/hrana-server.ts +15 -3
  82. package/src/image-optimizer-plugin.ts +194 -0
  83. package/src/ipc-tools-plugin.ts +16 -8
  84. package/src/kimaki-opencode-plugin.ts +1 -0
  85. package/src/logger.ts +0 -1
  86. package/src/markdown.ts +2 -2
  87. package/src/message-preprocessing.ts +117 -16
  88. package/src/onboarding-tutorial.ts +1 -1
  89. package/src/opencode-command-detection.test.ts +268 -0
  90. package/src/opencode-command-detection.ts +79 -0
  91. package/src/opencode-interrupt-plugin.test.ts +93 -50
  92. package/src/opencode-interrupt-plugin.ts +86 -9
  93. package/src/opencode.ts +16 -22
  94. package/src/plugin-logger.ts +68 -0
  95. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  96. package/src/queue-advanced-question.e2e.test.ts +243 -158
  97. package/src/sentry.ts +7 -120
  98. package/src/session-handler/event-stream-state.ts +1 -1
  99. package/src/session-handler/thread-runtime-state.ts +17 -0
  100. package/src/session-handler/thread-session-runtime.ts +232 -46
  101. package/src/session-title-rename.test.ts +112 -0
  102. package/src/store.ts +3 -8
  103. package/src/system-message.test.ts +612 -0
  104. package/src/system-message.ts +136 -63
  105. package/src/task-runner.ts +7 -4
  106. package/src/task-schedule.ts +3 -0
  107. package/src/thread-message-queue.e2e.test.ts +22 -11
  108. package/src/undici.d.ts +12 -0
  109. package/src/unnest-code-blocks.test.ts +34 -0
  110. package/src/unnest-code-blocks.ts +18 -1
  111. package/src/voice-handler.ts +18 -4
  112. package/src/voice.test.ts +2 -0
  113. package/src/voice.ts +68 -23
  114. package/src/worktrees.ts +152 -156
@@ -0,0 +1,153 @@
1
+ // Optimizes oversized images before they reach the LLM API.
2
+ // Prevents "image dimensions exceed max allowed" errors from Anthropic/Google/OpenAI.
3
+ // Hooks into tool.execute.after (read) and experimental.chat.messages.transform (clipboard paste).
4
+ // Uses sharp to resize images > 2000px and compress images > 4MB.
5
+ // Vendored from https://github.com/kargnas/opencode-large-image-optimizer, simplified to zero-config.
6
+ // Conservative safe floor for Anthropic many-image requests (20+ images = 2000px limit).
7
+ // OpenCode resends history so image counts accumulate across turns — 2000px is safest.
8
+ const MAX_DIMENSION = 2000;
9
+ // 4MB safe margin under Anthropic's 5MB limit
10
+ const MAX_FILE_SIZE = 4 * 1024 * 1024;
11
+ const SUPPORTED_MIMES = new Set([
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/jpg',
15
+ 'image/gif',
16
+ 'image/webp',
17
+ ]);
18
+ let sharpFactory;
19
+ async function getSharp() {
20
+ if (sharpFactory !== undefined) {
21
+ return sharpFactory;
22
+ }
23
+ try {
24
+ const mod = await import('sharp');
25
+ // sharp uses `export =` so it lands on .default in ESM interop
26
+ const fn = typeof mod === 'function' ? mod : mod.default;
27
+ if (typeof fn === 'function') {
28
+ sharpFactory = fn;
29
+ }
30
+ else {
31
+ sharpFactory = null;
32
+ }
33
+ }
34
+ catch {
35
+ sharpFactory = null;
36
+ }
37
+ return sharpFactory;
38
+ }
39
+ function extractBase64Data(dataUrl) {
40
+ const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/s);
41
+ if (match?.[1]) {
42
+ return match[1];
43
+ }
44
+ // raw base64 string (no data: prefix)
45
+ if (/^[A-Za-z0-9+/]+={0,2}$/.test(dataUrl)) {
46
+ return dataUrl;
47
+ }
48
+ return null;
49
+ }
50
+ async function optimizeImage(dataUrl, mime) {
51
+ const sharp = await getSharp();
52
+ if (!sharp) {
53
+ return null;
54
+ }
55
+ const rawBase64 = extractBase64Data(dataUrl);
56
+ if (!rawBase64) {
57
+ return null;
58
+ }
59
+ const inputBuffer = Buffer.from(rawBase64, 'base64');
60
+ if (inputBuffer.length === 0) {
61
+ return null;
62
+ }
63
+ const metadata = await sharp(inputBuffer).metadata();
64
+ const width = metadata.width || 0;
65
+ const height = metadata.height || 0;
66
+ if (width === 0 || height === 0) {
67
+ return null;
68
+ }
69
+ const needsResize = width > MAX_DIMENSION || height > MAX_DIMENSION;
70
+ const needsCompress = inputBuffer.length > MAX_FILE_SIZE;
71
+ if (!needsResize && !needsCompress) {
72
+ return null;
73
+ }
74
+ let pipeline = sharp(inputBuffer);
75
+ let outputMime = mime;
76
+ if (needsResize) {
77
+ pipeline = pipeline.resize(MAX_DIMENSION, MAX_DIMENSION, {
78
+ fit: 'inside',
79
+ withoutEnlargement: true,
80
+ });
81
+ }
82
+ let outputBuffer = await pipeline.toBuffer();
83
+ // if still over 4MB, convert to JPEG with progressive quality reduction
84
+ if (outputBuffer.length > MAX_FILE_SIZE) {
85
+ for (const quality of [100, 90, 80, 70]) {
86
+ outputBuffer = await sharp(outputBuffer)
87
+ .jpeg({ quality, mozjpeg: true })
88
+ .toBuffer();
89
+ outputMime = 'image/jpeg';
90
+ if (outputBuffer.length <= MAX_FILE_SIZE) {
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ return {
96
+ dataUrl: `data:${outputMime};base64,${outputBuffer.toString('base64')}`,
97
+ mime: outputMime,
98
+ };
99
+ }
100
+ // runtime guard — tool.execute.after output type doesn't declare attachments
101
+ function hasAttachments(value) {
102
+ return (typeof value === 'object' &&
103
+ value !== null &&
104
+ 'attachments' in value &&
105
+ Array.isArray(value.attachments));
106
+ }
107
+ const imageOptimizerPlugin = async () => {
108
+ return {
109
+ 'tool.execute.after': async (input, output) => {
110
+ const tool = input.tool.toLowerCase();
111
+ // read tool: optimize image attachments
112
+ if (tool === 'read' && hasAttachments(output)) {
113
+ for (const att of output.attachments) {
114
+ if (!att.mime ||
115
+ !att.url ||
116
+ !SUPPORTED_MIMES.has(att.mime.toLowerCase())) {
117
+ continue;
118
+ }
119
+ const result = await optimizeImage(att.url, att.mime).catch(() => null);
120
+ if (result) {
121
+ att.url = result.dataUrl;
122
+ att.mime = result.mime;
123
+ }
124
+ }
125
+ }
126
+ },
127
+ // clipboard paste: optimize file parts in message history
128
+ 'experimental.chat.messages.transform': async (_input, output) => {
129
+ if (!output.messages || !Array.isArray(output.messages)) {
130
+ return;
131
+ }
132
+ for (const msg of output.messages) {
133
+ if (!msg.parts || !Array.isArray(msg.parts)) {
134
+ continue;
135
+ }
136
+ for (const part of msg.parts) {
137
+ if (part.type !== 'file') {
138
+ continue;
139
+ }
140
+ if (!SUPPORTED_MIMES.has(part.mime.toLowerCase())) {
141
+ continue;
142
+ }
143
+ const result = await optimizeImage(part.url, part.mime).catch(() => null);
144
+ if (result) {
145
+ part.url = result.dataUrl;
146
+ part.mime = result.mime;
147
+ }
148
+ }
149
+ }
150
+ },
151
+ };
152
+ };
153
+ export { imageOptimizerPlugin };
@@ -9,9 +9,8 @@
9
9
  // plugin by OpenCode's plugin loader.
10
10
  import dedent from 'string-dedent';
11
11
  import { z } from 'zod';
12
- import { getPrisma, createIpcRequest, getIpcRequestById } from './database.js';
13
12
  import { setDataDir } from './config.js';
14
- import { createLogger, LogPrefix, setLogFilePath } from './logger.js';
13
+ import { createPluginLogger, setPluginLogFilePath } from './plugin-logger.js';
15
14
  import { initSentry } from './sentry.js';
16
15
  // Inlined from '@opencode-ai/plugin/tool' because the subpath value import
17
16
  // fails at runtime in global npm installs (#35). Opencode loads this plugin
@@ -27,10 +26,16 @@ import { initSentry } from './sentry.js';
27
26
  function tool(input) {
28
27
  return input;
29
28
  }
30
- const logger = createLogger(LogPrefix.OPENCODE);
29
+ const logger = createPluginLogger('OPENCODE');
31
30
  const FILE_UPLOAD_TIMEOUT_MS = 6 * 60 * 1000;
32
31
  const DEFAULT_FILE_UPLOAD_MAX_FILES = 5;
33
32
  const ACTION_BUTTON_TIMEOUT_MS = 30 * 1000;
33
+ async function loadDatabaseModule() {
34
+ // The plugin-loading e2e test boots OpenCode directly without the bot-side
35
+ // Hrana env vars. Lazy-loading avoids pulling Prisma + libsql sqlite mode
36
+ // during plugin startup when no IPC tool is being executed yet.
37
+ return import('./database.js');
38
+ }
34
39
  // @opencode-ai/plugin bundles zod 4.1.x as a hard dep; our code uses 4.3.x
35
40
  // (required by goke for ~standard.jsonSchema). The Plugin return type is
36
41
  // structurally incompatible due to _zod.version.minor skew even though
@@ -42,7 +47,7 @@ const ipcToolsPlugin = async () => {
42
47
  const dataDir = process.env.KIMAKI_DATA_DIR;
43
48
  if (dataDir) {
44
49
  setDataDir(dataDir);
45
- setLogFilePath(dataDir);
50
+ setPluginLogFilePath(dataDir);
46
51
  }
47
52
  return {
48
53
  tool: {
@@ -64,6 +69,7 @@ const ipcToolsPlugin = async () => {
64
69
  .describe('Maximum number of files the user can upload (1-10, default 5)'),
65
70
  },
66
71
  async execute({ prompt, maxFiles }, context) {
72
+ const { getPrisma, createIpcRequest, getIpcRequestById } = await loadDatabaseModule();
67
73
  const prisma = await getPrisma();
68
74
  const row = await prisma.thread_sessions.findFirst({
69
75
  where: { session_id: context.sessionID },
@@ -142,6 +148,7 @@ const ipcToolsPlugin = async () => {
142
148
  .describe('Array of 1-3 action buttons. Prefer one button whenever possible.'),
143
149
  },
144
150
  async execute({ buttons }, context) {
151
+ const { getPrisma, createIpcRequest, getIpcRequestById } = await loadDatabaseModule();
145
152
  const prisma = await getPrisma();
146
153
  const row = await prisma.thread_sessions.findFirst({
147
154
  where: { session_id: context.sessionID },
@@ -12,5 +12,6 @@ export { ipcToolsPlugin } from './ipc-tools-plugin.js';
12
12
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
13
13
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
14
14
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
15
+ export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
15
16
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
16
17
  export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
package/dist/logger.js CHANGED
@@ -135,7 +135,6 @@ export function createLogger(prefix) {
135
135
  }
136
136
  clackLog.message(formatMessage(getTimestamp(), pc.cyan(paddedPrefix), args), {
137
137
  ...noSpacing,
138
- // symbol: `|`,
139
138
  });
140
139
  };
141
140
  return {
package/dist/markdown.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Uses errore for type-safe error handling.
5
5
  import * as errore from 'errore';
6
6
  import { createTaggedError } from 'errore';
7
- import * as yaml from 'js-yaml';
7
+ import YAML from 'yaml';
8
8
  import { formatDateTime } from './utils.js';
9
9
  import { extractNonXmlContent } from './xml.js';
10
10
  import { createLogger, LogPrefix } from './logger.js';
@@ -175,7 +175,7 @@ export class ShareMarkdown {
175
175
  if (part.state.input && Object.keys(part.state.input).length > 0) {
176
176
  lines.push('**Input:**');
177
177
  lines.push('```yaml');
178
- lines.push(yaml.dump(part.state.input, { lineWidth: -1 }));
178
+ lines.push(YAML.stringify(part.state.input, null, { lineWidth: 0 }));
179
179
  lines.push('```');
180
180
  lines.push('');
181
181
  }
@@ -17,12 +17,33 @@ import { createLogger, LogPrefix } from './logger.js';
17
17
  import { notifyError } from './sentry.js';
18
18
  const logger = createLogger(LogPrefix.SESSION);
19
19
  const voiceLogger = createLogger(LogPrefix.VOICE);
20
+ export const VOICE_MESSAGE_TRANSCRIPTION_PREFIX = 'Voice message transcription from Discord user:\n';
21
+ /** Fetch available agents from OpenCode for voice transcription agent selection. */
22
+ async function fetchAvailableAgents(getClient) {
23
+ if (getClient instanceof Error) {
24
+ return [];
25
+ }
26
+ const result = await errore.tryAsync(() => {
27
+ return getClient().app.agents({});
28
+ });
29
+ if (result instanceof Error) {
30
+ return [];
31
+ }
32
+ return (result.data || [])
33
+ .filter((a) => {
34
+ return (a.mode === 'primary' || a.mode === 'all') && !a.hidden;
35
+ })
36
+ .map((a) => {
37
+ return { name: a.name, description: a.description };
38
+ });
39
+ }
20
40
  // Matches punctuation + "queue" at the end of a message (case-insensitive).
21
41
  // Supports any common punctuation before "queue" (. ! ? , ; :) and an optional
22
42
  // trailing period: ". queue", "! queue", ". queue.", "!queue." etc.
23
43
  // When present the suffix is stripped and the message is routed through
24
44
  // kimaki's local queue (same as /queue command).
25
45
  const QUEUE_SUFFIX_RE = /[.!?,;:]\s*queue\.?\s*$/i;
46
+ const REPLIED_MESSAGE_TEXT_LIMIT = 1_000;
26
47
  function extractQueueSuffix(prompt) {
27
48
  if (!QUEUE_SUFFIX_RE.test(prompt)) {
28
49
  return { prompt, forceQueue: false };
@@ -45,6 +66,28 @@ function shouldSkipEmptyPrompt({ message, prompt, images, hasVoiceAttachment, })
45
66
  voiceLogger.warn(`[INGRESS] Skipping empty prompt after preprocessing attachments=${message.attachments.size} hasVoiceAttachment=${hasVoiceAttachment} inferredVoiceAttachment=${inferredVoiceAttachment}`);
46
67
  return true;
47
68
  }
69
+ async function getRepliedMessageContext({ message, }) {
70
+ if (!message.reference?.messageId) {
71
+ return undefined;
72
+ }
73
+ const referencedMessage = await errore.tryAsync(() => {
74
+ return message.fetchReference();
75
+ });
76
+ if (referencedMessage instanceof Error) {
77
+ logger.warn(`[INGRESS] Failed to fetch replied message ${message.reference.messageId} for ${message.id}: ${referencedMessage.message}`);
78
+ return undefined;
79
+ }
80
+ const repliedText = resolveMentions(referencedMessage)
81
+ .trim()
82
+ .slice(0, REPLIED_MESSAGE_TEXT_LIMIT);
83
+ if (!repliedText) {
84
+ return undefined;
85
+ }
86
+ return {
87
+ authorUsername: referencedMessage.author.username,
88
+ text: repliedText,
89
+ };
90
+ }
48
91
  /**
49
92
  * Pre-process a message in an existing thread (thread already has a session or
50
93
  * needs a new one). Handles voice transcription, text/file attachments, and
@@ -71,9 +114,11 @@ export async function preprocessExistingThreadMessage({ message, thread, project
71
114
  let messageContent = isCliInjected
72
115
  ? (message.content || '')
73
116
  : resolveMentions(message);
74
- // Fetch session context for voice transcription enrichment
117
+ const repliedMessage = await getRepliedMessageContext({ message });
118
+ // Fetch session context and available agents for voice transcription enrichment
75
119
  let currentSessionContext;
76
120
  let lastSessionContext;
121
+ let agents = [];
77
122
  if (projectDirectory) {
78
123
  try {
79
124
  const getClient = await initializeOpencodeForDirectory(projectDirectory, { channelId });
@@ -82,19 +127,23 @@ export async function preprocessExistingThreadMessage({ message, thread, project
82
127
  throw new Error(getClient.message);
83
128
  }
84
129
  const client = getClient();
85
- const result = await getCompactSessionContext({
86
- client,
87
- sessionId,
88
- includeSystemPrompt: false,
89
- maxMessages: 15,
90
- });
91
- if (errore.isOk(result)) {
92
- currentSessionContext = result;
130
+ const [sessionContextResult, lastSessionResult, fetchedAgents] = await Promise.all([
131
+ getCompactSessionContext({
132
+ client,
133
+ sessionId,
134
+ includeSystemPrompt: false,
135
+ maxMessages: 15,
136
+ }),
137
+ getLastSessionId({
138
+ client,
139
+ excludeSessionId: sessionId,
140
+ }),
141
+ fetchAvailableAgents(getClient),
142
+ ]);
143
+ if (errore.isOk(sessionContextResult)) {
144
+ currentSessionContext = sessionContextResult;
93
145
  }
94
- const lastSessionResult = await getLastSessionId({
95
- client,
96
- excludeSessionId: sessionId,
97
- });
146
+ agents = fetchedAgents;
98
147
  const lastSessionId = errore.unwrapOr(lastSessionResult, null);
99
148
  if (lastSessionId) {
100
149
  const result = await getCompactSessionContext({
@@ -120,9 +169,10 @@ export async function preprocessExistingThreadMessage({ message, thread, project
120
169
  appId,
121
170
  currentSessionContext,
122
171
  lastSessionContext,
172
+ agents,
123
173
  });
124
174
  if (voiceResult) {
125
- messageContent = `Voice message transcription from Discord user:\n${voiceResult.transcription}`;
175
+ messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
126
176
  }
127
177
  // Voice transcription failed and no text — drop silently
128
178
  if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
@@ -148,7 +198,9 @@ export async function preprocessExistingThreadMessage({ message, thread, project
148
198
  return {
149
199
  prompt,
150
200
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
201
+ repliedMessage,
151
202
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
203
+ agent: voiceResult?.agent,
152
204
  };
153
205
  }
154
206
  /**
@@ -158,15 +210,29 @@ export async function preprocessExistingThreadMessage({ message, thread, project
158
210
  */
159
211
  export async function preprocessNewSessionMessage({ message, thread, projectDirectory, hasVoiceAttachment, appId, }) {
160
212
  logger.log(`No session for thread ${thread.id}, starting new session`);
213
+ // Fetch available agents only for voice messages to avoid unnecessary SDK
214
+ // roundtrips on plain text messages.
215
+ let agents = [];
216
+ if (hasVoiceAttachment && projectDirectory) {
217
+ try {
218
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
219
+ agents = await fetchAvailableAgents(getClient);
220
+ }
221
+ catch (e) {
222
+ voiceLogger.error(`Could not fetch agents for voice transcription:`, e);
223
+ }
224
+ }
161
225
  let prompt = resolveMentions(message);
226
+ const repliedMessage = await getRepliedMessageContext({ message });
162
227
  const voiceResult = await processVoiceAttachment({
163
228
  message,
164
229
  thread,
165
230
  projectDirectory,
166
231
  appId,
232
+ agents,
167
233
  });
168
234
  if (voiceResult) {
169
- prompt = `Voice message transcription from Discord user:\n${voiceResult.transcription}`;
235
+ prompt = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
170
236
  }
171
237
  // Voice transcription failed and no text — drop silently
172
238
  if (hasVoiceAttachment && !voiceResult && !prompt.trim()) {
@@ -199,7 +265,9 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
199
265
  }
200
266
  return {
201
267
  prompt: qs.prompt,
268
+ repliedMessage,
202
269
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
270
+ agent: voiceResult?.agent,
203
271
  };
204
272
  }
205
273
  /**
@@ -207,16 +275,30 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
207
275
  * Handles voice transcription and file/text attachments.
208
276
  */
209
277
  export async function preprocessNewThreadMessage({ message, thread, projectDirectory, hasVoiceAttachment, appId, }) {
278
+ // Fetch available agents only for voice messages to avoid unnecessary SDK
279
+ // roundtrips on plain text messages.
280
+ let agents = [];
281
+ if (hasVoiceAttachment && projectDirectory) {
282
+ try {
283
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
284
+ agents = await fetchAvailableAgents(getClient);
285
+ }
286
+ catch (e) {
287
+ voiceLogger.error(`Could not fetch agents for voice transcription:`, e);
288
+ }
289
+ }
210
290
  let messageContent = resolveMentions(message);
291
+ const repliedMessage = await getRepliedMessageContext({ message });
211
292
  const voiceResult = await processVoiceAttachment({
212
293
  message,
213
294
  thread,
214
295
  projectDirectory,
215
296
  isNewThread: true,
216
297
  appId,
298
+ agents,
217
299
  });
218
300
  if (voiceResult) {
219
- messageContent = `Voice message transcription from Discord user:\n${voiceResult.transcription}`;
301
+ messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
220
302
  }
221
303
  // Voice transcription failed and no text — drop silently
222
304
  if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
@@ -241,6 +323,8 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
241
323
  return {
242
324
  prompt,
243
325
  images: fileAttachments.length > 0 ? fileAttachments : undefined,
326
+ repliedMessage,
244
327
  mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
328
+ agent: voiceResult?.agent,
245
329
  };
246
330
  }
@@ -142,7 +142,7 @@ ${backticks}bash
142
142
  PORT=$((RANDOM % 6000 + 3000))
143
143
  tmux kill-session -t game-dev 2>/dev/null
144
144
  tmux new-session -d -s game-dev -c "$PWD"
145
- tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel --kill -p $PORT -- bun run server.ts" Enter
145
+ tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel -p $PORT -- bun run server.ts" Enter
146
146
  ${backticks}
147
147
 
148
148
  Wait a moment, then get the tunnel URL:
@@ -0,0 +1,70 @@
1
+ // Detect a /commandname token on its own line in a user prompt and resolve it
2
+ // to a registered opencode command. Mirrors the Discord slash command flow
3
+ // (commands/user-command.ts) so users can type `/build foo` or `/build-cmd foo`
4
+ // in chat, via `/new-session`, through `kimaki send --prompt`, or scheduled
5
+ // tasks and have it routed to opencode's session.command API instead of going
6
+ // to the model as plain text.
7
+ //
8
+ // Detection is line-based: we scan each line and return the first one whose
9
+ // first non-whitespace token is `/<registered-command>`. This keeps the
10
+ // detector oblivious to prefix lines (`» **kimaki-cli:**`, `Context from
11
+ // thread:`, etc). Producers that add such prefixes must put them on their
12
+ // own line so the user's content starts on a fresh line.
13
+ import { store } from './store.js';
14
+ const DISCORD_SUFFIXES = ['-mcp-prompt', '-skill', '-cmd'];
15
+ function stripDiscordSuffix(token) {
16
+ for (const suffix of DISCORD_SUFFIXES) {
17
+ if (token.endsWith(suffix)) {
18
+ return token.slice(0, -suffix.length);
19
+ }
20
+ }
21
+ return token;
22
+ }
23
+ function findRegisteredCommand({ token, registered, }) {
24
+ // Try exact matches first (original name, then Discord-sanitized name).
25
+ const exact = registered.find((c) => {
26
+ return c.name === token || c.discordCommandName === token;
27
+ });
28
+ if (exact)
29
+ return exact;
30
+ // Fall back to matching after stripping -cmd / -skill / -mcp-prompt from
31
+ // the user's token. This lets `/build-cmd` resolve to an opencode command
32
+ // whose base name is `build`.
33
+ const base = stripDiscordSuffix(token);
34
+ if (base === token)
35
+ return undefined;
36
+ return registered.find((c) => {
37
+ return c.name === base || c.discordCommandName === base;
38
+ });
39
+ }
40
+ export function extractLeadingOpencodeCommand(prompt, registered = store.getState().registeredUserCommands) {
41
+ if (!prompt)
42
+ return null;
43
+ if (registered.length === 0)
44
+ return null;
45
+ // Scan each line; the first line whose trimmed start is `/<token>` and
46
+ // resolves against registeredUserCommands wins. Args are everything after
47
+ // the command token on that line. Lines before and after are ignored —
48
+ // they're prefix (`» **name:**`) or context noise.
49
+ for (const line of prompt.split('\n')) {
50
+ const trimmed = line.trimStart();
51
+ if (!trimmed.startsWith('/'))
52
+ continue;
53
+ const match = trimmed.match(/^\/([^\s]+)(?:\s+(.*))?$/);
54
+ if (!match)
55
+ continue;
56
+ const [, token, rest] = match;
57
+ if (!token)
58
+ continue;
59
+ const resolved = findRegisteredCommand({ token, registered });
60
+ if (!resolved)
61
+ continue;
62
+ return {
63
+ command: {
64
+ name: resolved.name,
65
+ arguments: (rest ?? '').trim(),
66
+ },
67
+ };
68
+ }
69
+ return null;
70
+ }