kimaki 0.4.78 → 0.4.80
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/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
package/dist/hrana-server.js
CHANGED
|
@@ -31,15 +31,79 @@
|
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import http from 'node:http';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
+
import crypto from 'node:crypto';
|
|
34
35
|
import Database from 'libsql';
|
|
35
36
|
import * as errore from 'errore';
|
|
36
37
|
import { createLogger, LogPrefix } from './logger.js';
|
|
37
38
|
import { ServerStartError, FetchError } from './errors.js';
|
|
38
39
|
import { getLockPort } from './config.js';
|
|
40
|
+
import { store } from './store.js';
|
|
39
41
|
const hranaLogger = createLogger(LogPrefix.DB);
|
|
40
42
|
let db = null;
|
|
41
43
|
let server = null;
|
|
42
44
|
let hranaUrl = null;
|
|
45
|
+
let discordGatewayReady = false;
|
|
46
|
+
let readyWaiters = [];
|
|
47
|
+
export function markDiscordGatewayReady() {
|
|
48
|
+
if (discordGatewayReady) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
discordGatewayReady = true;
|
|
52
|
+
for (const resolve of readyWaiters) {
|
|
53
|
+
resolve();
|
|
54
|
+
}
|
|
55
|
+
readyWaiters = [];
|
|
56
|
+
}
|
|
57
|
+
async function waitForDiscordGatewayReady({ timeoutMs }) {
|
|
58
|
+
if (discordGatewayReady) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const readyPromise = new Promise((resolve) => {
|
|
62
|
+
readyWaiters.push(() => {
|
|
63
|
+
resolve(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
resolve(false);
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
});
|
|
71
|
+
return Promise.race([readyPromise, timeoutPromise]);
|
|
72
|
+
}
|
|
73
|
+
function getRequestAuthToken(req) {
|
|
74
|
+
const authorizationHeader = req.headers.authorization;
|
|
75
|
+
if (typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')) {
|
|
76
|
+
return authorizationHeader.slice('Bearer '.length);
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// Timing-safe comparison to prevent timing attacks when the hrana server
|
|
81
|
+
// is internet-facing (bindAll=true / KIMAKI_INTERNET_REACHABLE_URL set).
|
|
82
|
+
function isAuthorizedRequest(req) {
|
|
83
|
+
const expectedToken = store.getState().gatewayToken;
|
|
84
|
+
if (!expectedToken) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const providedToken = getRequestAuthToken(req);
|
|
88
|
+
if (!providedToken) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const expectedBuf = Buffer.from(expectedToken, 'utf8');
|
|
92
|
+
const providedBuf = Buffer.from(providedToken, 'utf8');
|
|
93
|
+
if (expectedBuf.length !== providedBuf.length) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return crypto.timingSafeEqual(expectedBuf, providedBuf);
|
|
97
|
+
}
|
|
98
|
+
function ensureServiceAuthTokenInStore() {
|
|
99
|
+
const existingToken = store.getState().gatewayToken;
|
|
100
|
+
if (existingToken) {
|
|
101
|
+
return existingToken;
|
|
102
|
+
}
|
|
103
|
+
const generatedToken = `${crypto.randomUUID()}:${crypto.randomBytes(32).toString('hex')}`;
|
|
104
|
+
store.setState({ gatewayToken: generatedToken });
|
|
105
|
+
return generatedToken;
|
|
106
|
+
}
|
|
43
107
|
/**
|
|
44
108
|
* Get the Hrana HTTP URL for injecting into plugin child processes.
|
|
45
109
|
* Returns null if the server hasn't been started yet.
|
|
@@ -54,18 +118,62 @@ export function getHranaUrl() {
|
|
|
54
118
|
* Handles single-instance enforcement: if the port is occupied, kills the
|
|
55
119
|
* existing process first.
|
|
56
120
|
*/
|
|
57
|
-
export async function startHranaServer({ dbPath, }) {
|
|
121
|
+
export async function startHranaServer({ dbPath, bindAll = false, }) {
|
|
58
122
|
if (server && db && hranaUrl)
|
|
59
123
|
return hranaUrl;
|
|
60
124
|
const port = getLockPort();
|
|
125
|
+
const bindHost = bindAll ? '0.0.0.0' : '127.0.0.1';
|
|
126
|
+
const serviceAuthToken = ensureServiceAuthTokenInStore();
|
|
127
|
+
process.env.KIMAKI_DB_AUTH_TOKEN = serviceAuthToken;
|
|
61
128
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
62
129
|
await evictExistingInstance({ port });
|
|
63
|
-
hranaLogger.log(`Starting hrana server on
|
|
130
|
+
hranaLogger.log(`Starting hrana server on ${bindHost}:${port} with db: ${dbPath}`);
|
|
64
131
|
const database = new Database(dbPath);
|
|
65
132
|
database.exec('PRAGMA journal_mode = WAL');
|
|
66
133
|
database.exec('PRAGMA busy_timeout = 5000');
|
|
67
134
|
db = database;
|
|
68
|
-
const
|
|
135
|
+
const hranaHandler = createHranaHandler(database);
|
|
136
|
+
// Combined handler: all control/data routes require the same service auth token.
|
|
137
|
+
const handler = async (req, res) => {
|
|
138
|
+
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
|
139
|
+
if (pathname === '/kimaki/wake') {
|
|
140
|
+
if (req.method !== 'POST') {
|
|
141
|
+
res.writeHead(405, { 'content-type': 'application/json' });
|
|
142
|
+
res.end(JSON.stringify({ error: 'method_not_allowed' }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!isAuthorizedRequest(req)) {
|
|
146
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
147
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const isReady = await waitForDiscordGatewayReady({ timeoutMs: 30_000 });
|
|
151
|
+
if (!isReady) {
|
|
152
|
+
res.writeHead(504, { 'content-type': 'application/json' });
|
|
153
|
+
res.end(JSON.stringify({ ready: false, error: 'timeout_waiting_for_discord_ready' }));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
157
|
+
res.end(JSON.stringify({ ready: true }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Hrana routes: /health, /v2, /v2/pipeline
|
|
161
|
+
if (pathname === '/health') {
|
|
162
|
+
hranaHandler(req, res);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (pathname === '/v2' || pathname === '/v2/pipeline') {
|
|
166
|
+
if (!isAuthorizedRequest(req)) {
|
|
167
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
168
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
hranaHandler(req, res);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
res.writeHead(404);
|
|
175
|
+
res.end();
|
|
176
|
+
};
|
|
69
177
|
const started = await new Promise((resolve) => {
|
|
70
178
|
const srv = http.createServer(handler);
|
|
71
179
|
srv.on('error', (err) => {
|
|
@@ -76,7 +184,7 @@ export async function startHranaServer({ dbPath, }) {
|
|
|
76
184
|
: err.message,
|
|
77
185
|
}));
|
|
78
186
|
});
|
|
79
|
-
srv.listen(port,
|
|
187
|
+
srv.listen(port, bindHost, () => {
|
|
80
188
|
server = srv;
|
|
81
189
|
resolve(true);
|
|
82
190
|
});
|
|
@@ -108,6 +216,8 @@ export async function stopHranaServer() {
|
|
|
108
216
|
db = null;
|
|
109
217
|
}
|
|
110
218
|
hranaUrl = null;
|
|
219
|
+
discordGatewayReady = false;
|
|
220
|
+
readyWaiters = [];
|
|
111
221
|
hranaLogger.log('Hrana server stopped');
|
|
112
222
|
}
|
|
113
223
|
// ── Value encoding/decoding ──────────────────────────────────────────────
|
|
@@ -7,6 +7,7 @@ import { handleNewWorktreeCommand, handleNewWorktreeAutocomplete, } from './comm
|
|
|
7
7
|
import { handleMergeWorktreeCommand, handleMergeWorktreeAutocomplete, } from './commands/merge-worktree.js';
|
|
8
8
|
import { handleToggleWorktreesCommand } from './commands/worktree-settings.js';
|
|
9
9
|
import { handleWorktreesCommand } from './commands/worktrees.js';
|
|
10
|
+
import { handleTasksCommand } from './commands/tasks.js';
|
|
10
11
|
import { handleToggleMentionModeCommand } from './commands/mention-mode.js';
|
|
11
12
|
import { handleResumeCommand, handleResumeAutocomplete, } from './commands/resume.js';
|
|
12
13
|
import { handleAddProjectCommand, handleAddProjectAutocomplete, } from './commands/add-project.js';
|
|
@@ -20,7 +21,7 @@ import { handleDiffCommand } from './commands/diff.js';
|
|
|
20
21
|
import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
|
|
21
22
|
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
|
|
22
23
|
import { handleUnsetModelCommand } from './commands/unset-model.js';
|
|
23
|
-
import { handleLoginCommand,
|
|
24
|
+
import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
|
|
24
25
|
import { handleTranscriptionApiKeyButton, handleTranscriptionApiKeyCommand, handleTranscriptionApiKeyModalSubmit, } from './commands/gemini-apikey.js';
|
|
25
26
|
import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand, } from './commands/agent.js';
|
|
26
27
|
import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
|
|
@@ -112,6 +113,12 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
112
113
|
appId,
|
|
113
114
|
});
|
|
114
115
|
return;
|
|
116
|
+
case 'tasks':
|
|
117
|
+
await handleTasksCommand({
|
|
118
|
+
command: interaction,
|
|
119
|
+
appId,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
115
122
|
case 'toggle-mention-mode':
|
|
116
123
|
await handleToggleMentionModeCommand({
|
|
117
124
|
command: interaction,
|
|
@@ -260,6 +267,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
260
267
|
await handleFileUploadButton(interaction);
|
|
261
268
|
return;
|
|
262
269
|
}
|
|
270
|
+
if (customId.startsWith('login_text_btn:')) {
|
|
271
|
+
await handleLoginTextButton(interaction);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (customId.startsWith('login_apikey_btn:')) {
|
|
275
|
+
await handleLoginApiKeyButton(interaction);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (customId.startsWith('login_oauth_code_btn:')) {
|
|
279
|
+
await handleOAuthCodeButton(interaction);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
263
282
|
if (customId.startsWith('action_button:')) {
|
|
264
283
|
await handleActionButton(interaction);
|
|
265
284
|
return;
|
|
@@ -323,12 +342,8 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
323
342
|
await handleMcpSelectMenu(interaction);
|
|
324
343
|
return;
|
|
325
344
|
}
|
|
326
|
-
if (customId.startsWith('
|
|
327
|
-
await
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
if (customId.startsWith('login_method:')) {
|
|
331
|
-
await handleLoginMethodSelectMenu(interaction);
|
|
345
|
+
if (customId.startsWith('login_select:')) {
|
|
346
|
+
await handleLoginSelect(interaction);
|
|
332
347
|
return;
|
|
333
348
|
}
|
|
334
349
|
return;
|
|
@@ -346,6 +361,14 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
346
361
|
await handleApiKeyModalSubmit(interaction);
|
|
347
362
|
return;
|
|
348
363
|
}
|
|
364
|
+
if (customId.startsWith('login_text:')) {
|
|
365
|
+
await handleLoginTextModalSubmit(interaction);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (customId.startsWith('login_oauth_code:')) {
|
|
369
|
+
await handleOAuthCodeModalSubmit(interaction);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
349
372
|
if (customId.startsWith('transcription_apikey_modal:')) {
|
|
350
373
|
await handleTranscriptionApiKeyModalSubmit(interaction);
|
|
351
374
|
return;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// OpenCode plugin that provides IPC-based tools for Discord interaction:
|
|
2
|
+
// - kimaki_file_upload: prompts the Discord user to upload files via native picker
|
|
3
|
+
// - kimaki_action_buttons: shows clickable action buttons in the Discord thread
|
|
4
|
+
//
|
|
5
|
+
// Tools communicate with the bot process via IPC rows in SQLite (the plugin
|
|
6
|
+
// runs inside the OpenCode server process, not the bot process).
|
|
7
|
+
//
|
|
8
|
+
// Exported from opencode-plugin.ts — each export is treated as a separate
|
|
9
|
+
// plugin by OpenCode's plugin loader.
|
|
10
|
+
import dedent from 'string-dedent';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { getPrisma, createIpcRequest, getIpcRequestById } from './database.js';
|
|
13
|
+
import { setDataDir } from './config.js';
|
|
14
|
+
import { createLogger, LogPrefix, setLogFilePath } from './logger.js';
|
|
15
|
+
import { initSentry } from './sentry.js';
|
|
16
|
+
// Inlined from '@opencode-ai/plugin/tool' because the subpath value import
|
|
17
|
+
// fails at runtime in global npm installs (#35). Opencode loads this plugin
|
|
18
|
+
// file in its own process and resolves modules from kimaki's install dir,
|
|
19
|
+
// but the '/tool' subpath export isn't found by opencode's module resolver.
|
|
20
|
+
// The type-only imports above are fine (erased at compile time).
|
|
21
|
+
//
|
|
22
|
+
// NOTE: @opencode-ai/plugin bundles its own zod 4.1.x as a hard dependency
|
|
23
|
+
// while goke (used by cli.ts) requires zod 4.3.x. This version skew makes
|
|
24
|
+
// the Plugin return type structurally incompatible with our local tool()
|
|
25
|
+
// even though runtime behavior is identical. ipcToolsPlugin is cast to
|
|
26
|
+
// Plugin via unknown to bypass this purely type-level incompatibility.
|
|
27
|
+
function tool(input) {
|
|
28
|
+
return input;
|
|
29
|
+
}
|
|
30
|
+
const logger = createLogger(LogPrefix.OPENCODE);
|
|
31
|
+
const FILE_UPLOAD_TIMEOUT_MS = 6 * 60 * 1000;
|
|
32
|
+
const DEFAULT_FILE_UPLOAD_MAX_FILES = 5;
|
|
33
|
+
const ACTION_BUTTON_TIMEOUT_MS = 30 * 1000;
|
|
34
|
+
// @opencode-ai/plugin bundles zod 4.1.x as a hard dep; our code uses 4.3.x
|
|
35
|
+
// (required by goke for ~standard.jsonSchema). The Plugin return type is
|
|
36
|
+
// structurally incompatible due to _zod.version.minor skew even though
|
|
37
|
+
// runtime behavior is identical. `any` bypasses the type-level mismatch —
|
|
38
|
+
// opencode's plugin loader doesn't care about the zod version at runtime.
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
const ipcToolsPlugin = async () => {
|
|
41
|
+
initSentry();
|
|
42
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
43
|
+
if (dataDir) {
|
|
44
|
+
setDataDir(dataDir);
|
|
45
|
+
setLogFilePath(dataDir);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
tool: {
|
|
49
|
+
kimaki_file_upload: tool({
|
|
50
|
+
description: 'Prompt the Discord user to upload files using a native file picker modal. ' +
|
|
51
|
+
'The user sees a button, clicks it, and gets a file upload dialog. ' +
|
|
52
|
+
'Returns the local file paths of downloaded files in the project directory. ' +
|
|
53
|
+
'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
|
|
54
|
+
'IMPORTANT: Always call this tool last in your message, after all text parts.',
|
|
55
|
+
args: {
|
|
56
|
+
prompt: z
|
|
57
|
+
.string()
|
|
58
|
+
.describe('Message shown to the user explaining what files to upload'),
|
|
59
|
+
maxFiles: z
|
|
60
|
+
.number()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(10)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Maximum number of files the user can upload (1-10, default 5)'),
|
|
65
|
+
},
|
|
66
|
+
async execute({ prompt, maxFiles }, context) {
|
|
67
|
+
const prisma = await getPrisma();
|
|
68
|
+
const row = await prisma.thread_sessions.findFirst({
|
|
69
|
+
where: { session_id: context.sessionID },
|
|
70
|
+
select: { thread_id: true },
|
|
71
|
+
});
|
|
72
|
+
if (!row?.thread_id) {
|
|
73
|
+
return 'Could not find thread for current session';
|
|
74
|
+
}
|
|
75
|
+
const ipcRow = await createIpcRequest({
|
|
76
|
+
type: 'file_upload',
|
|
77
|
+
sessionId: context.sessionID,
|
|
78
|
+
threadId: row.thread_id,
|
|
79
|
+
payload: JSON.stringify({
|
|
80
|
+
prompt,
|
|
81
|
+
maxFiles: maxFiles || DEFAULT_FILE_UPLOAD_MAX_FILES,
|
|
82
|
+
directory: context.directory,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
const deadline = Date.now() + FILE_UPLOAD_TIMEOUT_MS;
|
|
86
|
+
const POLL_INTERVAL_MS = 300;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
setTimeout(resolve, POLL_INTERVAL_MS);
|
|
90
|
+
});
|
|
91
|
+
const updated = await getIpcRequestById({ id: ipcRow.id });
|
|
92
|
+
if (!updated || updated.status === 'cancelled') {
|
|
93
|
+
return 'File upload was cancelled';
|
|
94
|
+
}
|
|
95
|
+
if (updated.response) {
|
|
96
|
+
const parsed = JSON.parse(updated.response);
|
|
97
|
+
if (parsed.error) {
|
|
98
|
+
return `File upload failed: ${parsed.error}`;
|
|
99
|
+
}
|
|
100
|
+
const filePaths = parsed.filePaths || [];
|
|
101
|
+
if (filePaths.length === 0) {
|
|
102
|
+
return 'No files were uploaded (user may have cancelled or sent a new message)';
|
|
103
|
+
}
|
|
104
|
+
return `Files uploaded successfully:\n${filePaths.join('\n')}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return 'File upload timed out - user did not upload files within the time limit';
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
kimaki_action_buttons: tool({
|
|
111
|
+
description: dedent `
|
|
112
|
+
Show action buttons in the current Discord thread for quick confirmations.
|
|
113
|
+
Use this when the user can respond by clicking one of up to 3 buttons.
|
|
114
|
+
Prefer a single button whenever possible.
|
|
115
|
+
Default color is white (same visual style as permission deny button).
|
|
116
|
+
If you need more than 3 options, use the question tool instead.
|
|
117
|
+
IMPORTANT: Always call this tool last in your message, after all text parts.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
- buttons: [{"label":"Yes, proceed"}]
|
|
121
|
+
- buttons: [{"label":"Approve","color":"green"}]
|
|
122
|
+
- buttons: [
|
|
123
|
+
{"label":"Confirm","color":"blue"},
|
|
124
|
+
{"label":"Cancel","color":"white"}
|
|
125
|
+
]
|
|
126
|
+
`,
|
|
127
|
+
args: {
|
|
128
|
+
buttons: z
|
|
129
|
+
.array(z.object({
|
|
130
|
+
label: z
|
|
131
|
+
.string()
|
|
132
|
+
.min(1)
|
|
133
|
+
.max(80)
|
|
134
|
+
.describe('Button label shown to the user (1-80 chars)'),
|
|
135
|
+
color: z
|
|
136
|
+
.enum(['white', 'blue', 'green', 'red'])
|
|
137
|
+
.optional()
|
|
138
|
+
.describe('Optional button color. white is default and preferred for most confirmations.'),
|
|
139
|
+
}))
|
|
140
|
+
.min(1)
|
|
141
|
+
.max(3)
|
|
142
|
+
.describe('Array of 1-3 action buttons. Prefer one button whenever possible.'),
|
|
143
|
+
},
|
|
144
|
+
async execute({ buttons }, context) {
|
|
145
|
+
const prisma = await getPrisma();
|
|
146
|
+
const row = await prisma.thread_sessions.findFirst({
|
|
147
|
+
where: { session_id: context.sessionID },
|
|
148
|
+
select: { thread_id: true },
|
|
149
|
+
});
|
|
150
|
+
if (!row?.thread_id) {
|
|
151
|
+
return 'Could not find thread for current session';
|
|
152
|
+
}
|
|
153
|
+
const ipcRow = await createIpcRequest({
|
|
154
|
+
type: 'action_buttons',
|
|
155
|
+
sessionId: context.sessionID,
|
|
156
|
+
threadId: row.thread_id,
|
|
157
|
+
payload: JSON.stringify({
|
|
158
|
+
buttons,
|
|
159
|
+
directory: context.directory,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
const deadline = Date.now() + ACTION_BUTTON_TIMEOUT_MS;
|
|
163
|
+
const POLL_INTERVAL_MS = 200;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
await new Promise((resolve) => {
|
|
166
|
+
setTimeout(resolve, POLL_INTERVAL_MS);
|
|
167
|
+
});
|
|
168
|
+
const updated = await getIpcRequestById({ id: ipcRow.id });
|
|
169
|
+
if (!updated || updated.status === 'cancelled') {
|
|
170
|
+
return 'Action button request was cancelled';
|
|
171
|
+
}
|
|
172
|
+
if (updated.response) {
|
|
173
|
+
const parsed = JSON.parse(updated.response);
|
|
174
|
+
if (parsed.error) {
|
|
175
|
+
return `Action button request failed: ${parsed.error}`;
|
|
176
|
+
}
|
|
177
|
+
return `Action button(s) shown: ${buttons.map((button) => button.label).join(', ')}`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return 'Action button request timed out';
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
export { ipcToolsPlugin };
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// preserving arrival order without a separate threadIngressQueue.
|
|
9
9
|
import { resolveMentions, getFileAttachments, getTextAttachments, } from './message-formatting.js';
|
|
10
10
|
import { processVoiceAttachment } from './voice-handler.js';
|
|
11
|
+
import { isVoiceAttachment } from './voice-attachment.js';
|
|
11
12
|
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
12
13
|
import { getCompactSessionContext, getLastSessionId } from './markdown.js';
|
|
13
14
|
import { getThreadSession } from './database.js';
|
|
@@ -28,6 +29,22 @@ function extractQueueSuffix(prompt) {
|
|
|
28
29
|
}
|
|
29
30
|
return { prompt: prompt.replace(QUEUE_SUFFIX_RE, '').trimEnd(), forceQueue: true };
|
|
30
31
|
}
|
|
32
|
+
function shouldSkipEmptyPrompt({ message, prompt, images, hasVoiceAttachment, }) {
|
|
33
|
+
if (prompt.trim()) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if ((images?.length || 0) > 0) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const inferredVoiceAttachment = message.attachments.some((attachment) => {
|
|
40
|
+
return isVoiceAttachment(attachment);
|
|
41
|
+
});
|
|
42
|
+
if (!hasVoiceAttachment && !inferredVoiceAttachment && message.attachments.size === 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
voiceLogger.warn(`[INGRESS] Skipping empty prompt after preprocessing attachments=${message.attachments.size} hasVoiceAttachment=${hasVoiceAttachment} inferredVoiceAttachment=${inferredVoiceAttachment}`);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
31
48
|
/**
|
|
32
49
|
* Pre-process a message in an existing thread (thread already has a session or
|
|
33
50
|
* needs a new one). Handles voice transcription, text/file attachments, and
|
|
@@ -111,14 +128,25 @@ export async function preprocessExistingThreadMessage({ message, thread, project
|
|
|
111
128
|
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
112
129
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
113
130
|
}
|
|
131
|
+
// Extract queue suffix from raw message content BEFORE appending text
|
|
132
|
+
// attachments. Otherwise a text file attachment pushes "? queue" away from
|
|
133
|
+
// the end of the string and the regex fails to match.
|
|
134
|
+
const qs = extractQueueSuffix(messageContent);
|
|
114
135
|
const fileAttachments = await getFileAttachments(message);
|
|
115
136
|
const textAttachmentsContent = await getTextAttachments(message);
|
|
116
|
-
const
|
|
117
|
-
? `${
|
|
118
|
-
:
|
|
119
|
-
|
|
137
|
+
const prompt = textAttachmentsContent
|
|
138
|
+
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
139
|
+
: qs.prompt;
|
|
140
|
+
if (shouldSkipEmptyPrompt({
|
|
141
|
+
message,
|
|
142
|
+
prompt,
|
|
143
|
+
images: fileAttachments,
|
|
144
|
+
hasVoiceAttachment,
|
|
145
|
+
})) {
|
|
146
|
+
return { prompt: '', mode: 'opencode', skip: true };
|
|
147
|
+
}
|
|
120
148
|
return {
|
|
121
|
-
prompt
|
|
149
|
+
prompt,
|
|
122
150
|
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
123
151
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
124
152
|
};
|
|
@@ -148,7 +176,7 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
148
176
|
const starterMessage = await thread
|
|
149
177
|
.fetchStarterMessage()
|
|
150
178
|
.catch((error) => {
|
|
151
|
-
logger.warn(`[SESSION] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.
|
|
179
|
+
logger.warn(`[SESSION] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.stack : String(error));
|
|
152
180
|
return null;
|
|
153
181
|
});
|
|
154
182
|
if (starterMessage && starterMessage.content !== message.content) {
|
|
@@ -162,6 +190,13 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
162
190
|
}
|
|
163
191
|
}
|
|
164
192
|
const qs = extractQueueSuffix(prompt);
|
|
193
|
+
if (shouldSkipEmptyPrompt({
|
|
194
|
+
message,
|
|
195
|
+
prompt: qs.prompt,
|
|
196
|
+
hasVoiceAttachment,
|
|
197
|
+
})) {
|
|
198
|
+
return { prompt: '', mode: 'opencode', skip: true };
|
|
199
|
+
}
|
|
165
200
|
return {
|
|
166
201
|
prompt: qs.prompt,
|
|
167
202
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
@@ -187,14 +222,24 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
|
|
|
187
222
|
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
188
223
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
189
224
|
}
|
|
225
|
+
// Extract queue suffix from raw message content BEFORE appending text
|
|
226
|
+
// attachments (same fix as preprocessExistingThreadMessage).
|
|
227
|
+
const qs = extractQueueSuffix(messageContent);
|
|
190
228
|
const fileAttachments = await getFileAttachments(message);
|
|
191
229
|
const textAttachmentsContent = await getTextAttachments(message);
|
|
192
|
-
const
|
|
193
|
-
? `${
|
|
194
|
-
:
|
|
195
|
-
|
|
230
|
+
const prompt = textAttachmentsContent
|
|
231
|
+
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
232
|
+
: qs.prompt;
|
|
233
|
+
if (shouldSkipEmptyPrompt({
|
|
234
|
+
message,
|
|
235
|
+
prompt,
|
|
236
|
+
images: fileAttachments,
|
|
237
|
+
hasVoiceAttachment,
|
|
238
|
+
})) {
|
|
239
|
+
return { prompt: '', mode: 'opencode', skip: true };
|
|
240
|
+
}
|
|
196
241
|
return {
|
|
197
|
-
prompt
|
|
242
|
+
prompt,
|
|
198
243
|
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
199
244
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
200
245
|
};
|
|
@@ -32,6 +32,6 @@ export async function sendWelcomeMessage({ channel, mentionUserId, }) {
|
|
|
32
32
|
logger.log(`Sent welcome message with thread to #${channel.name}`);
|
|
33
33
|
}
|
|
34
34
|
catch (error) {
|
|
35
|
-
logger.warn(`Failed to send welcome message to #${channel.name}: ${error instanceof Error ? error.
|
|
35
|
+
logger.warn(`Failed to send welcome message to #${channel.name}: ${error instanceof Error ? error.stack : String(error)}`);
|
|
36
36
|
}
|
|
37
37
|
}
|