kimaki 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/cli.js +5 -0
- package/dist/cloudflare-transcription.js +56 -0
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model-variant.js +17 -1
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/queue.js +64 -0
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/config.js +9 -0
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +8 -6
- package/dist/discord-command-registration.js +17 -12
- package/dist/external-directory-permissions.e2e.test.js +82 -0
- package/dist/external-directory-project-deny.e2e.test.js +68 -0
- package/dist/format-tables.js +53 -5
- package/dist/format-tables.test.js +121 -0
- package/dist/interaction-handler.js +8 -6
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/message-preprocessing.js +2 -25
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/opencode.js +126 -96
- package/dist/queue-advanced-e2e-setup.js +63 -27
- package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +95 -57
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/voice-handler.js +37 -11
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/cli.ts +11 -0
- package/src/cloudflare-transcription.ts +76 -0
- package/src/commands/agent.ts +120 -84
- package/src/commands/model-variant.ts +26 -0
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/queue.ts +83 -0
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/config.ts +10 -0
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -6
- package/src/discord-command-registration.ts +22 -15
- package/src/external-directory-permissions.e2e.test.ts +99 -0
- package/src/external-directory-project-deny.e2e.test.ts +82 -0
- package/src/format-tables.test.ts +126 -0
- package/src/format-tables.ts +68 -5
- package/src/interaction-handler.ts +10 -5
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/message-preprocessing.ts +2 -37
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/opencode.ts +132 -111
- package/src/queue-advanced-e2e-setup.ts +78 -29
- package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-runtime-state.ts +0 -4
- package/src/session-handler/thread-session-runtime.ts +108 -69
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/voice-handler.ts +37 -10
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
- package/dist/channel-reference-permissions.e2e.test.js +0 -87
- package/dist/commands/add-dir.js +0 -169
- package/dist/commands/add-dir.test.js +0 -126
- package/src/channel-reference-permissions.e2e.test.ts +0 -104
- package/src/commands/add-dir.test.ts +0 -154
- package/src/commands/add-dir.ts +0 -244
package/dist/task-runner.js
CHANGED
|
@@ -3,11 +3,15 @@ import { Routes } from 'discord.js';
|
|
|
3
3
|
import { createDiscordRest } from './discord-urls.js';
|
|
4
4
|
import { ensureThreadMember } from './discord-utils.js';
|
|
5
5
|
import YAML from 'yaml';
|
|
6
|
-
import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, } from './database.js';
|
|
6
|
+
import { claimScheduledTaskRunning, createScheduledTaskRun, failScheduledTaskRun, finishScheduledTaskRun, getDuePlannedScheduledTasks, getScheduledTask, getActiveScheduledTaskRuns, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, releaseScheduledTaskClaim, setScheduledTaskRunThread, } from './database.js';
|
|
7
|
+
import { execAsync } from './exec-async.js';
|
|
8
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
7
9
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
8
10
|
import { notifyError } from './sentry.js';
|
|
9
|
-
import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
11
|
+
import { appendTaskCommandOutput, getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
10
12
|
const taskLogger = createLogger(LogPrefix.TASK);
|
|
13
|
+
const MAX_SCHEDULED_PROMPT_LENGTH = 1_900;
|
|
14
|
+
const PENDING_RUN_TIMEOUT_MS = 120_000;
|
|
11
15
|
function isRecord(value) {
|
|
12
16
|
return typeof value === 'object' && value !== null;
|
|
13
17
|
}
|
|
@@ -20,7 +24,7 @@ function parseMessageId(value) {
|
|
|
20
24
|
}
|
|
21
25
|
return value.id;
|
|
22
26
|
}
|
|
23
|
-
async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
27
|
+
async function executeThreadScheduledTask({ rest, task, payload, prompt, runId, }) {
|
|
24
28
|
const marker = {
|
|
25
29
|
start: true,
|
|
26
30
|
scheduledKind: task.schedule_kind,
|
|
@@ -28,6 +32,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
28
32
|
// after execution, so the ID would be stale by the time the bot processes
|
|
29
33
|
// the Discord event and tries to insert a session_start_sources row.
|
|
30
34
|
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
35
|
+
...(runId ? { scheduledTaskRunId: runId } : {}),
|
|
31
36
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
32
37
|
...(payload.model ? { model: payload.model } : {}),
|
|
33
38
|
...(payload.username ? { username: payload.username } : {}),
|
|
@@ -41,7 +46,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
41
46
|
const embed = [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }];
|
|
42
47
|
// Newline between prefix and prompt so leading /command detection can
|
|
43
48
|
// find the command on its own line.
|
|
44
|
-
const prefixedPrompt = `» **kimaki-cli:**\n${
|
|
49
|
+
const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
|
|
45
50
|
// Re-join the user before posting, so the message they get notified about is
|
|
46
51
|
// in a thread they are already a member of. Works on archived threads too;
|
|
47
52
|
// the post below auto-unarchives.
|
|
@@ -69,8 +74,9 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
69
74
|
});
|
|
70
75
|
if (postResult instanceof Error)
|
|
71
76
|
return postResult;
|
|
77
|
+
return payload.threadId;
|
|
72
78
|
}
|
|
73
|
-
async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
79
|
+
async function executeChannelScheduledTask({ rest, task, payload, prompt, runId, }) {
|
|
74
80
|
const marker = payload.notifyOnly
|
|
75
81
|
? undefined
|
|
76
82
|
: {
|
|
@@ -78,6 +84,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
78
84
|
scheduledKind: task.schedule_kind,
|
|
79
85
|
// Only include scheduledTaskId for cron tasks (see thread variant comment)
|
|
80
86
|
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
87
|
+
...(runId ? { scheduledTaskRunId: runId } : {}),
|
|
81
88
|
...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
|
|
82
89
|
...(payload.cwd ? { cwd: payload.cwd } : {}),
|
|
83
90
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
@@ -96,7 +103,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
96
103
|
const starterResult = await rest
|
|
97
104
|
.post(Routes.channelMessages(payload.channelId), {
|
|
98
105
|
body: {
|
|
99
|
-
content:
|
|
106
|
+
content: prompt,
|
|
100
107
|
embeds,
|
|
101
108
|
},
|
|
102
109
|
})
|
|
@@ -113,7 +120,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
113
120
|
cause: starterMessageId,
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
|
-
const threadName = (payload.name || getPromptPreview(
|
|
123
|
+
const threadName = (payload.name || getPromptPreview(prompt)).slice(0, 100);
|
|
117
124
|
const threadResult = await rest
|
|
118
125
|
.post(Routes.threads(payload.channelId, starterMessageId), {
|
|
119
126
|
body: {
|
|
@@ -128,15 +135,14 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
128
135
|
});
|
|
129
136
|
if (threadResult instanceof Error)
|
|
130
137
|
return threadResult;
|
|
131
|
-
if (!payload.userId) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
138
|
const threadIdResult = parseMessageId(threadResult);
|
|
135
139
|
if (threadIdResult instanceof Error) {
|
|
136
140
|
return new Error(`Invalid thread response for task ${task.id}`, {
|
|
137
141
|
cause: threadIdResult,
|
|
138
142
|
});
|
|
139
143
|
}
|
|
144
|
+
if (!payload.userId)
|
|
145
|
+
return threadIdResult;
|
|
140
146
|
const addMemberResult = await ensureThreadMember({
|
|
141
147
|
rest,
|
|
142
148
|
threadId: threadIdResult,
|
|
@@ -145,25 +151,111 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
145
151
|
if (addMemberResult instanceof Error) {
|
|
146
152
|
return new Error(`Failed to add user to scheduled thread for task ${task.id}`, { cause: addMemberResult });
|
|
147
153
|
}
|
|
154
|
+
return threadIdResult;
|
|
155
|
+
}
|
|
156
|
+
export async function runTaskCommand({ task, payload, }) {
|
|
157
|
+
if (!payload.preRunCommand)
|
|
158
|
+
return { kind: 'run', prompt: payload.prompt };
|
|
159
|
+
if (!task.project_directory) {
|
|
160
|
+
return new Error(`Task ${task.id} has a pre-run command but no project directory`);
|
|
161
|
+
}
|
|
162
|
+
const command = payload.preRunCommand;
|
|
163
|
+
const result = await execAsync(command, { cwd: task.project_directory }).catch((error) => error instanceof Error ? error : new Error(String(error)));
|
|
164
|
+
const stdout = isRecord(result) && typeof result.stdout === 'string'
|
|
165
|
+
? result.stdout
|
|
166
|
+
: '';
|
|
167
|
+
const stderr = isRecord(result) && typeof result.stderr === 'string'
|
|
168
|
+
? result.stderr
|
|
169
|
+
: '';
|
|
170
|
+
const exitCode = result instanceof Error && isRecord(result)
|
|
171
|
+
? result.code ?? 1
|
|
172
|
+
: 0;
|
|
173
|
+
if (stdout)
|
|
174
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run stdout:\n${stdout}`);
|
|
175
|
+
if (stderr)
|
|
176
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run stderr:\n${stderr}`);
|
|
177
|
+
taskLogger.log(`[task-runner] task ${task.id} pre-run exited with ${exitCode}`);
|
|
178
|
+
if (result instanceof Error)
|
|
179
|
+
return { kind: 'skip' };
|
|
180
|
+
const prompt = appendTaskCommandOutput({ prompt: payload.prompt, stdout });
|
|
181
|
+
if (prompt.length > MAX_SCHEDULED_PROMPT_LENGTH) {
|
|
182
|
+
return new Error(`Task ${task.id} prompt and pre-run stdout exceed ${MAX_SCHEDULED_PROMPT_LENGTH} characters`);
|
|
183
|
+
}
|
|
184
|
+
return { kind: 'run', prompt };
|
|
185
|
+
}
|
|
186
|
+
async function hasRunningSession(task) {
|
|
187
|
+
const runs = await getActiveScheduledTaskRuns(task.id);
|
|
188
|
+
if (runs.length === 0)
|
|
189
|
+
return false;
|
|
190
|
+
let active = false;
|
|
191
|
+
for (const run of runs) {
|
|
192
|
+
if (!run.session_id) {
|
|
193
|
+
if (Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
|
|
194
|
+
active = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
await failScheduledTaskRun({
|
|
198
|
+
runId: run.id,
|
|
199
|
+
error: 'Timed out waiting for scheduled session to start',
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!run.project_directory) {
|
|
204
|
+
active = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const getClient = await initializeOpencodeForDirectory(run.project_directory);
|
|
208
|
+
if (getClient instanceof Error)
|
|
209
|
+
return getClient;
|
|
210
|
+
const statusResponse = await getClient().session.status({
|
|
211
|
+
directory: run.project_directory,
|
|
212
|
+
}).catch((error) => new Error('Failed to check scheduled session status', {
|
|
213
|
+
cause: error,
|
|
214
|
+
}));
|
|
215
|
+
if (statusResponse instanceof Error)
|
|
216
|
+
return statusResponse;
|
|
217
|
+
if (statusResponse.error)
|
|
218
|
+
return new Error('Failed to check scheduled session status');
|
|
219
|
+
const status = statusResponse.data?.[run.session_id];
|
|
220
|
+
if (status && status.type !== 'idle') {
|
|
221
|
+
active = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!status && Date.now() - run.started_at.getTime() < PENDING_RUN_TIMEOUT_MS) {
|
|
225
|
+
active = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
await finishScheduledTaskRun({ runId: run.id, status: 'completed' });
|
|
229
|
+
}
|
|
230
|
+
return active;
|
|
148
231
|
}
|
|
149
|
-
async function executeScheduledTask({ rest, task, }) {
|
|
232
|
+
async function executeScheduledTask({ rest, task, runId, }) {
|
|
150
233
|
const payloadResult = parseScheduledTaskPayload(task.payload_json);
|
|
151
234
|
if (payloadResult instanceof Error) {
|
|
152
235
|
return new Error(`Task ${task.id} has invalid payload`, {
|
|
153
236
|
cause: payloadResult,
|
|
154
237
|
});
|
|
155
238
|
}
|
|
239
|
+
const commandResult = await runTaskCommand({ task, payload: payloadResult });
|
|
240
|
+
if (commandResult instanceof Error)
|
|
241
|
+
return commandResult;
|
|
242
|
+
if (commandResult.kind === 'skip')
|
|
243
|
+
return { kind: 'condition-not-met' };
|
|
156
244
|
if (payloadResult.kind === 'thread') {
|
|
157
245
|
return executeThreadScheduledTask({
|
|
158
246
|
rest,
|
|
159
247
|
task,
|
|
160
248
|
payload: payloadResult,
|
|
249
|
+
prompt: commandResult.prompt,
|
|
250
|
+
runId,
|
|
161
251
|
});
|
|
162
252
|
}
|
|
163
253
|
return executeChannelScheduledTask({
|
|
164
254
|
rest,
|
|
165
255
|
task,
|
|
166
256
|
payload: payloadResult,
|
|
257
|
+
prompt: commandResult.prompt,
|
|
258
|
+
runId,
|
|
167
259
|
});
|
|
168
260
|
}
|
|
169
261
|
async function finalizeSuccessfulTask({ task, completedAt, }) {
|
|
@@ -234,7 +326,32 @@ async function processDueTask({ rest, task, }) {
|
|
|
234
326
|
if (!claimed) {
|
|
235
327
|
return { kind: 'skipped' };
|
|
236
328
|
}
|
|
237
|
-
const
|
|
329
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
330
|
+
if (payload instanceof Error) {
|
|
331
|
+
await finalizeFailedTask({ task, failedAt: new Date(), error: payload });
|
|
332
|
+
return { kind: 'failed', error: payload };
|
|
333
|
+
}
|
|
334
|
+
const activeSession = payload.allowConcurrency
|
|
335
|
+
? false
|
|
336
|
+
: await hasRunningSession(task);
|
|
337
|
+
if (activeSession instanceof Error) {
|
|
338
|
+
await releaseScheduledTaskClaim(task.id);
|
|
339
|
+
taskLogger.warn(`[task-runner] could not check concurrency for task ${task.id}: ${formatErrorWithStack(activeSession)}`);
|
|
340
|
+
return { kind: 'failed', error: activeSession };
|
|
341
|
+
}
|
|
342
|
+
if (activeSession) {
|
|
343
|
+
if (task.schedule_kind === 'cron') {
|
|
344
|
+
await finalizeSuccessfulTask({ task, completedAt: new Date() });
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
await releaseScheduledTaskClaim(task.id);
|
|
348
|
+
}
|
|
349
|
+
return { kind: 'concurrency-blocked' };
|
|
350
|
+
}
|
|
351
|
+
const runId = task.schedule_kind === 'cron'
|
|
352
|
+
? await createScheduledTaskRun({ taskId: task.id, startedAt })
|
|
353
|
+
: undefined;
|
|
354
|
+
const executeResult = await executeScheduledTask({ rest, task, runId });
|
|
238
355
|
const finishedAt = new Date();
|
|
239
356
|
if (executeResult instanceof Error) {
|
|
240
357
|
taskLogger.warn(`[task-runner] task ${task.id} failed: ${formatErrorWithStack(executeResult)}`);
|
|
@@ -243,8 +360,22 @@ async function processDueTask({ rest, task, }) {
|
|
|
243
360
|
failedAt: finishedAt,
|
|
244
361
|
error: executeResult,
|
|
245
362
|
});
|
|
363
|
+
if (runId)
|
|
364
|
+
await failScheduledTaskRun({ runId, error: executeResult.message });
|
|
246
365
|
return { kind: 'failed', error: executeResult };
|
|
247
366
|
}
|
|
367
|
+
if (!(typeof executeResult === 'string' || executeResult === null)) {
|
|
368
|
+
if (runId)
|
|
369
|
+
await finishScheduledTaskRun({ runId, status: 'skipped' });
|
|
370
|
+
await finalizeSuccessfulTask({ task, completedAt: finishedAt });
|
|
371
|
+
return executeResult;
|
|
372
|
+
}
|
|
373
|
+
if (executeResult && runId) {
|
|
374
|
+
await setScheduledTaskRunThread({ runId, threadId: executeResult });
|
|
375
|
+
}
|
|
376
|
+
else if (runId) {
|
|
377
|
+
await finishScheduledTaskRun({ runId, status: 'completed' });
|
|
378
|
+
}
|
|
248
379
|
await finalizeSuccessfulTask({ task, completedAt: finishedAt });
|
|
249
380
|
return { kind: 'success' };
|
|
250
381
|
}
|
package/dist/task-schedule.js
CHANGED
|
@@ -106,6 +106,12 @@ export function getNextCronRun({ cronExpr, timezone, from, }) {
|
|
|
106
106
|
export function serializeScheduledTaskPayload(payload) {
|
|
107
107
|
return JSON.stringify(payload);
|
|
108
108
|
}
|
|
109
|
+
export function appendTaskCommandOutput({ prompt, stdout, }) {
|
|
110
|
+
const output = stdout.trim();
|
|
111
|
+
if (!output)
|
|
112
|
+
return prompt;
|
|
113
|
+
return `${prompt}\n\n## Pre-run command output\n\n${output}`;
|
|
114
|
+
}
|
|
109
115
|
function isRecord(value) {
|
|
110
116
|
return typeof value === 'object' && value !== null;
|
|
111
117
|
}
|
|
@@ -135,6 +141,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
135
141
|
return new Error('Task payload must be an object');
|
|
136
142
|
}
|
|
137
143
|
const kind = asString(parsed.kind);
|
|
144
|
+
const preRunCommand = asString(parsed.preRunCommand);
|
|
145
|
+
const allowConcurrency = parsed.allowConcurrency === true;
|
|
138
146
|
if (kind === 'thread') {
|
|
139
147
|
const threadId = asString(parsed.threadId);
|
|
140
148
|
const prompt = asString(parsed.prompt);
|
|
@@ -159,6 +167,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
159
167
|
permissions,
|
|
160
168
|
injectionGuardPatterns,
|
|
161
169
|
parentSessionId,
|
|
170
|
+
preRunCommand,
|
|
171
|
+
allowConcurrency,
|
|
162
172
|
};
|
|
163
173
|
}
|
|
164
174
|
if (kind === 'channel') {
|
|
@@ -194,6 +204,8 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
194
204
|
permissions,
|
|
195
205
|
injectionGuardPatterns,
|
|
196
206
|
parentSessionId,
|
|
207
|
+
preRunCommand,
|
|
208
|
+
allowConcurrency,
|
|
197
209
|
};
|
|
198
210
|
}
|
|
199
211
|
return new Error('Task payload has unknown kind');
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// Tests for scheduled task date/cron parsing and UTC validation rules.
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterAll, afterEach, describe, expect, test } from 'vitest';
|
|
6
|
+
import { completeScheduledTaskRunsForSession, createScheduledTask, createScheduledTaskRun, getActiveScheduledTaskRuns, getScheduledTask, startScheduledTaskRunSession, } from './database.js';
|
|
7
|
+
import { closeDb } from './db.js';
|
|
8
|
+
import { runTaskCommand } from './task-runner.js';
|
|
9
|
+
import { appendTaskCommandOutput, parseScheduledTaskPayload, parseSendAtValue, } from './task-schedule.js';
|
|
4
10
|
describe('parseSendAtValue', () => {
|
|
5
11
|
test('accepts UTC ISO date ending with Z', () => {
|
|
6
12
|
const now = new Date('2026-02-22T13:00:00Z');
|
|
@@ -69,3 +75,144 @@ describe('parseSendAtValue', () => {
|
|
|
69
75
|
expect(result.nextRunAt.toISOString()).toBe('2026-02-23T09:00:00.000Z');
|
|
70
76
|
});
|
|
71
77
|
});
|
|
78
|
+
describe('scheduled task execution options', () => {
|
|
79
|
+
const tempDirectories = [];
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
82
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
test('defaults old payloads to no command and no concurrency', () => {
|
|
86
|
+
const payload = parseScheduledTaskPayload(JSON.stringify({
|
|
87
|
+
kind: 'thread',
|
|
88
|
+
threadId: 'thread-1',
|
|
89
|
+
prompt: 'Handle support requests',
|
|
90
|
+
}));
|
|
91
|
+
expect(payload).toMatchInlineSnapshot(`
|
|
92
|
+
{
|
|
93
|
+
"agent": null,
|
|
94
|
+
"allowConcurrency": false,
|
|
95
|
+
"injectionGuardPatterns": null,
|
|
96
|
+
"kind": "thread",
|
|
97
|
+
"model": null,
|
|
98
|
+
"parentSessionId": null,
|
|
99
|
+
"permissions": null,
|
|
100
|
+
"preRunCommand": null,
|
|
101
|
+
"prompt": "Handle support requests",
|
|
102
|
+
"threadId": "thread-1",
|
|
103
|
+
"userId": null,
|
|
104
|
+
"username": null,
|
|
105
|
+
}
|
|
106
|
+
`);
|
|
107
|
+
});
|
|
108
|
+
test('adds command stdout to the prompt', () => {
|
|
109
|
+
expect(appendTaskCommandOutput({
|
|
110
|
+
prompt: 'Handle support requests',
|
|
111
|
+
stdout: '{"ticketId":"ticket-123"}\n',
|
|
112
|
+
})).toMatchInlineSnapshot(`
|
|
113
|
+
"Handle support requests
|
|
114
|
+
|
|
115
|
+
## Pre-run command output
|
|
116
|
+
|
|
117
|
+
{"ticketId":"ticket-123"}"
|
|
118
|
+
`);
|
|
119
|
+
});
|
|
120
|
+
test('runs the pre-run command in the project directory', async () => {
|
|
121
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
|
|
122
|
+
tempDirectories.push(directory);
|
|
123
|
+
fs.writeFileSync(path.join(directory, 'should-run.ts'), "process.stdout.write(JSON.stringify({ cwd: process.cwd(), ticketId: 'ticket-123' }))\n");
|
|
124
|
+
const taskId = await createScheduledTask({
|
|
125
|
+
scheduleKind: 'cron',
|
|
126
|
+
cronExpr: '*/5 * * * *',
|
|
127
|
+
timezone: 'UTC',
|
|
128
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
129
|
+
payloadJson: JSON.stringify({
|
|
130
|
+
kind: 'thread',
|
|
131
|
+
threadId: 'thread-1',
|
|
132
|
+
prompt: 'Handle support requests',
|
|
133
|
+
preRunCommand: 'tsx should-run.ts',
|
|
134
|
+
}),
|
|
135
|
+
promptPreview: 'Handle support requests',
|
|
136
|
+
projectDirectory: directory,
|
|
137
|
+
});
|
|
138
|
+
const task = await getScheduledTask(taskId);
|
|
139
|
+
if (!task)
|
|
140
|
+
throw new Error('Expected scheduled task');
|
|
141
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
142
|
+
if (payload instanceof Error)
|
|
143
|
+
throw payload;
|
|
144
|
+
const result = await runTaskCommand({ task, payload });
|
|
145
|
+
const realDirectory = fs.realpathSync(directory);
|
|
146
|
+
expect(result instanceof Error || result.kind === 'skip' ? result : {
|
|
147
|
+
...result,
|
|
148
|
+
prompt: result.prompt.replace(realDirectory, '<project-directory>'),
|
|
149
|
+
}).toMatchInlineSnapshot(`
|
|
150
|
+
{
|
|
151
|
+
"kind": "run",
|
|
152
|
+
"prompt": "Handle support requests
|
|
153
|
+
|
|
154
|
+
## Pre-run command output
|
|
155
|
+
|
|
156
|
+
{"cwd":"<project-directory>","ticketId":"ticket-123"}",
|
|
157
|
+
}
|
|
158
|
+
`);
|
|
159
|
+
});
|
|
160
|
+
test('skips the task when the pre-run command exits nonzero', async () => {
|
|
161
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-task-'));
|
|
162
|
+
tempDirectories.push(directory);
|
|
163
|
+
const taskId = await createScheduledTask({
|
|
164
|
+
scheduleKind: 'cron',
|
|
165
|
+
cronExpr: '*/5 * * * *',
|
|
166
|
+
timezone: 'UTC',
|
|
167
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
168
|
+
payloadJson: JSON.stringify({
|
|
169
|
+
kind: 'thread',
|
|
170
|
+
threadId: 'thread-skip',
|
|
171
|
+
prompt: 'Handle support requests',
|
|
172
|
+
preRunCommand: "node -e \"process.stdout.write('nothing pending'); process.exit(1)\"",
|
|
173
|
+
}),
|
|
174
|
+
promptPreview: 'Handle support requests',
|
|
175
|
+
projectDirectory: directory,
|
|
176
|
+
});
|
|
177
|
+
const task = await getScheduledTask(taskId);
|
|
178
|
+
if (!task)
|
|
179
|
+
throw new Error('Expected scheduled task');
|
|
180
|
+
const payload = parseScheduledTaskPayload(task.payload_json);
|
|
181
|
+
if (payload instanceof Error)
|
|
182
|
+
throw payload;
|
|
183
|
+
expect(await runTaskCommand({ task, payload })).toMatchInlineSnapshot(`
|
|
184
|
+
{
|
|
185
|
+
"kind": "skip",
|
|
186
|
+
}
|
|
187
|
+
`);
|
|
188
|
+
});
|
|
189
|
+
test('tracks active runs until their OpenCode session completes', async () => {
|
|
190
|
+
const taskId = await createScheduledTask({
|
|
191
|
+
scheduleKind: 'cron',
|
|
192
|
+
cronExpr: '*/5 * * * *',
|
|
193
|
+
timezone: 'UTC',
|
|
194
|
+
nextRunAt: new Date('2026-08-12T18:00:00Z'),
|
|
195
|
+
payloadJson: JSON.stringify({
|
|
196
|
+
kind: 'thread',
|
|
197
|
+
threadId: 'thread-2',
|
|
198
|
+
prompt: 'Handle support requests',
|
|
199
|
+
}),
|
|
200
|
+
promptPreview: 'Handle support requests',
|
|
201
|
+
});
|
|
202
|
+
const runId = await createScheduledTaskRun({
|
|
203
|
+
taskId,
|
|
204
|
+
startedAt: new Date('2026-08-12T18:00:00Z'),
|
|
205
|
+
});
|
|
206
|
+
await startScheduledTaskRunSession({
|
|
207
|
+
runId,
|
|
208
|
+
sessionId: 'session-1',
|
|
209
|
+
projectDirectory: '/project',
|
|
210
|
+
});
|
|
211
|
+
expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(1);
|
|
212
|
+
await completeScheduledTaskRunsForSession('session-1');
|
|
213
|
+
expect(await getActiveScheduledTaskRuns(taskId)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
afterAll(async () => {
|
|
217
|
+
await closeDb();
|
|
218
|
+
});
|
package/dist/voice-handler.js
CHANGED
|
@@ -11,7 +11,8 @@ import * as prism from 'prism-media';
|
|
|
11
11
|
import dedent from 'string-dedent';
|
|
12
12
|
import { Events, } from 'discord.js';
|
|
13
13
|
import { createGenAIWorker } from './genai-worker-wrapper.js';
|
|
14
|
-
import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, findTextChannelByVoiceChannel, } from './database.js';
|
|
14
|
+
import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, getBotTokenWithMode, findTextChannelByVoiceChannel, } from './database.js';
|
|
15
|
+
import { transcribeViaKimakiGateway } from './cloudflare-transcription.js';
|
|
15
16
|
import { sendThreadMessage, escapeDiscordFormatting, NOTIFY_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
|
|
16
17
|
import { requestAudioApiKey } from './commands/gemini-apikey.js';
|
|
17
18
|
import { transcribeAudio } from './voice.js';
|
|
@@ -446,7 +447,30 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
446
447
|
transcriptionApiKey = process.env.GEMINI_API_KEY;
|
|
447
448
|
transcriptionProvider = 'gemini';
|
|
448
449
|
}
|
|
450
|
+
// No user-configured key: gateway-mode installs get one free transcription
|
|
451
|
+
// via the kimaki.dev Whisper fallback before we bother the user with the
|
|
452
|
+
// "add API key" dialog. This fallback has no tool-calling, so it can't
|
|
453
|
+
// detect queueMessage/agent hints spoken in the voice message the way the
|
|
454
|
+
// OpenAI/Gemini path can.
|
|
455
|
+
let gatewayTranscription;
|
|
449
456
|
if (!transcriptionApiKey) {
|
|
457
|
+
const botRow = await getBotTokenWithMode().catch(() => undefined);
|
|
458
|
+
if (botRow?.mode === 'gateway' && botRow.clientId && botRow.clientSecret) {
|
|
459
|
+
const result = await transcribeViaKimakiGateway({
|
|
460
|
+
audio: audioBuffer,
|
|
461
|
+
mediaType: audioAttachment.contentType || undefined,
|
|
462
|
+
clientId: botRow.clientId,
|
|
463
|
+
clientSecret: botRow.clientSecret,
|
|
464
|
+
});
|
|
465
|
+
if (result instanceof Error) {
|
|
466
|
+
voiceLogger.log(`Kimaki gateway transcription fallback failed:`, result);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
gatewayTranscription = result;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!transcriptionApiKey && !gatewayTranscription) {
|
|
450
474
|
if (!appId) {
|
|
451
475
|
await sendThreadMessage(thread, 'Voice transcription requires an API key. Set OPENAI_API_KEY or GEMINI_API_KEY, or use /login in this channel.');
|
|
452
476
|
return null;
|
|
@@ -461,16 +485,18 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
461
485
|
transcriptionApiKey = requested.apiKey;
|
|
462
486
|
transcriptionProvider = requested.provider;
|
|
463
487
|
}
|
|
464
|
-
const transcription =
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
488
|
+
const transcription = gatewayTranscription
|
|
489
|
+
? { transcription: gatewayTranscription, queueMessage: false, agent: undefined }
|
|
490
|
+
: await transcribeAudio({
|
|
491
|
+
audio: audioBuffer,
|
|
492
|
+
prompt: transcriptionPrompt,
|
|
493
|
+
apiKey: transcriptionApiKey,
|
|
494
|
+
provider: transcriptionProvider,
|
|
495
|
+
mediaType: audioAttachment.contentType || undefined,
|
|
496
|
+
currentSessionContext,
|
|
497
|
+
lastSessionContext,
|
|
498
|
+
agents,
|
|
499
|
+
});
|
|
474
500
|
if (transcription instanceof Error) {
|
|
475
501
|
const errMsg = errore.matchError(transcription, {
|
|
476
502
|
ApiKeyMissingError: (e) => e.message,
|