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.
- package/dist/agent-model.e2e.test.js +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- 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 };
|
package/dist/ipc-tools-plugin.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
+
}
|