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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Free Whisper transcription fallback via the kimaki.dev Cloudflare Worker.
|
|
2
|
+
// Only usable in gateway mode: the worker authenticates the request against
|
|
3
|
+
// the shared gateway_clients Postgres table using the CLI's own
|
|
4
|
+
// clientId:clientSecret pair (the same credentials already used for
|
|
5
|
+
// gateway-proxy REST/WebSocket calls), then runs
|
|
6
|
+
// @cf/openai/whisper-large-v3-turbo on Cloudflare's own Workers AI account.
|
|
7
|
+
// This means transcription costs Kimaki, never the user, so voice messages
|
|
8
|
+
// work out of the box for gateway-mode installs with no OpenAI/Gemini key
|
|
9
|
+
// configured.
|
|
10
|
+
//
|
|
11
|
+
// See website/src/server.tsx POST /api/transcribe for the server side.
|
|
12
|
+
// Rate limits (per-client burst + daily request cap) are enforced there,
|
|
13
|
+
// not here — a 429 from the worker just means this fallback is unavailable
|
|
14
|
+
// right now and the caller should fall back to the "add API key" dialog.
|
|
15
|
+
import { TranscriptionError } from './errors.js';
|
|
16
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
17
|
+
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
18
|
+
const KIMAKI_TRANSCRIBE_URL = process.env.KIMAKI_TRANSCRIBE_URL || 'https://kimaki.dev/api/transcribe';
|
|
19
|
+
export async function transcribeViaKimakiGateway({ audio, mediaType, clientId, clientSecret, }) {
|
|
20
|
+
const response = await fetch(KIMAKI_TRANSCRIBE_URL, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${clientId}:${clientSecret}`,
|
|
24
|
+
'Content-Type': mediaType || 'audio/ogg',
|
|
25
|
+
},
|
|
26
|
+
body: audio,
|
|
27
|
+
}).catch((cause) => {
|
|
28
|
+
return new TranscriptionError({
|
|
29
|
+
reason: 'Failed to reach kimaki.dev transcription endpoint',
|
|
30
|
+
cause,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
if (response instanceof TranscriptionError)
|
|
34
|
+
return response;
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const bodyText = await response.text().catch(() => '');
|
|
37
|
+
voiceLogger.log(`Kimaki gateway transcription failed: ${response.status} ${bodyText}`);
|
|
38
|
+
return new TranscriptionError({
|
|
39
|
+
reason: `kimaki.dev returned ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const data = (await response.json().catch((cause) => {
|
|
43
|
+
return new TranscriptionError({
|
|
44
|
+
reason: 'Failed to parse transcription response',
|
|
45
|
+
cause,
|
|
46
|
+
});
|
|
47
|
+
}));
|
|
48
|
+
if (data instanceof TranscriptionError)
|
|
49
|
+
return data;
|
|
50
|
+
if (!data.text) {
|
|
51
|
+
return new TranscriptionError({
|
|
52
|
+
reason: 'Empty transcription response from kimaki gateway',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return data.text;
|
|
56
|
+
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -9,7 +9,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
|
9
9
|
import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
10
10
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
|
-
import { getCurrentModelInfo } from './model.js';
|
|
12
|
+
import { formatModelSource, getCurrentModelInfo, } from './model.js';
|
|
13
13
|
import { isGitRepositoryRoot } from '../worktrees.js';
|
|
14
14
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
|
|
15
15
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
@@ -100,7 +100,7 @@ async function resolveQuickAgentNameFromInteraction({ command, }) {
|
|
|
100
100
|
* Resolve the context for an agent command (directory, channel, session).
|
|
101
101
|
* Returns null if the command cannot be executed in this context.
|
|
102
102
|
*/
|
|
103
|
-
export async function resolveAgentCommandContext({ interaction, }) {
|
|
103
|
+
export async function resolveAgentCommandContext({ interaction, appId, }) {
|
|
104
104
|
const channel = interaction.channel;
|
|
105
105
|
if (!channel) {
|
|
106
106
|
await interaction.editReply({
|
|
@@ -113,39 +113,35 @@ export async function resolveAgentCommandContext({ interaction, }) {
|
|
|
113
113
|
ChannelType.PrivateThread,
|
|
114
114
|
ChannelType.AnnouncementThread,
|
|
115
115
|
].includes(channel.type);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let sessionId;
|
|
119
|
-
if (isThread) {
|
|
120
|
-
const thread = channel;
|
|
121
|
-
const textChannel = await resolveTextChannel(thread);
|
|
122
|
-
const metadata = await getKimakiMetadata(textChannel);
|
|
123
|
-
projectDirectory = metadata.projectDirectory;
|
|
124
|
-
targetChannelId = textChannel?.id || channel.id;
|
|
125
|
-
sessionId = await getThreadSession(thread.id);
|
|
126
|
-
}
|
|
127
|
-
else if (channel.type === ChannelType.GuildText) {
|
|
128
|
-
const metadata = await getKimakiMetadata(channel);
|
|
129
|
-
projectDirectory = metadata.projectDirectory;
|
|
130
|
-
targetChannelId = channel.id;
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
116
|
+
if (!isThread &&
|
|
117
|
+
channel.type !== ChannelType.GuildText) {
|
|
133
118
|
await interaction.editReply({
|
|
134
119
|
content: 'This command can only be used in text channels or threads',
|
|
135
120
|
});
|
|
136
121
|
return null;
|
|
137
122
|
}
|
|
138
|
-
|
|
123
|
+
const resolved = await resolveWorkingDirectory({
|
|
124
|
+
channel: channel,
|
|
125
|
+
});
|
|
126
|
+
if (!resolved) {
|
|
139
127
|
await interaction.editReply({
|
|
140
128
|
content: 'This channel is not configured with a project directory',
|
|
141
129
|
});
|
|
142
130
|
return null;
|
|
143
131
|
}
|
|
132
|
+
const textChannel = isThread
|
|
133
|
+
? await resolveTextChannel(channel)
|
|
134
|
+
: undefined;
|
|
135
|
+
const sessionId = isThread
|
|
136
|
+
? await getThreadSession(channel.id)
|
|
137
|
+
: undefined;
|
|
144
138
|
return {
|
|
145
|
-
dir: projectDirectory,
|
|
146
|
-
|
|
139
|
+
dir: resolved.projectDirectory,
|
|
140
|
+
workingDirectory: resolved.workingDirectory,
|
|
141
|
+
channelId: textChannel?.id || channel.id,
|
|
147
142
|
sessionId,
|
|
148
143
|
isThread,
|
|
144
|
+
appId,
|
|
149
145
|
};
|
|
150
146
|
}
|
|
151
147
|
/**
|
|
@@ -165,6 +161,42 @@ export async function setAgentForContext({ context, agentName, }) {
|
|
|
165
161
|
agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
|
|
166
162
|
}
|
|
167
163
|
}
|
|
164
|
+
function formatAgentModelLine(modelInfo) {
|
|
165
|
+
if (modelInfo.type === 'none')
|
|
166
|
+
return '';
|
|
167
|
+
const source = formatModelSource({
|
|
168
|
+
type: modelInfo.type,
|
|
169
|
+
agentName: modelInfo.type === 'agent' ? modelInfo.agentName : undefined,
|
|
170
|
+
});
|
|
171
|
+
const line = `\nModel: *${modelInfo.model}* (${source})`;
|
|
172
|
+
if (modelInfo.type !== 'session' && modelInfo.type !== 'channel') {
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
return `${line}\nThis model comes from a ${modelInfo.type} override. Use /model and press Clear override if you want this agent's model.`;
|
|
176
|
+
}
|
|
177
|
+
function formatAgentPreferenceReply({ agentName, previousAgentName, modelInfo, scope, }) {
|
|
178
|
+
const sameAgent = previousAgentName === agentName;
|
|
179
|
+
const verb = sameAgent ? 'Using' : 'Switched to';
|
|
180
|
+
const previousText = !sameAgent && previousAgentName ? ` (was **${previousAgentName}**)` : '';
|
|
181
|
+
const modelText = formatAgentModelLine(modelInfo);
|
|
182
|
+
if (scope === 'session') {
|
|
183
|
+
return `${verb} **${agentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`;
|
|
184
|
+
}
|
|
185
|
+
return `${verb} **${agentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`;
|
|
186
|
+
}
|
|
187
|
+
async function resolveAgentModelInfo({ context, agentName, }) {
|
|
188
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
189
|
+
if (getClient instanceof Error)
|
|
190
|
+
return { type: 'none' };
|
|
191
|
+
return getCurrentModelInfo({
|
|
192
|
+
sessionId: context.sessionId,
|
|
193
|
+
channelId: context.channelId,
|
|
194
|
+
appId: context.appId,
|
|
195
|
+
agentPreference: agentName,
|
|
196
|
+
getClient,
|
|
197
|
+
directory: context.workingDirectory,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
168
200
|
export async function handleAgentCommand({ interaction, appId, }) {
|
|
169
201
|
await interaction.deferReply();
|
|
170
202
|
const context = await resolveAgentCommandContext({ interaction, appId });
|
|
@@ -178,7 +210,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
178
210
|
return;
|
|
179
211
|
}
|
|
180
212
|
const agentsResponse = await getClient().app.agents({
|
|
181
|
-
directory: context.
|
|
213
|
+
directory: context.workingDirectory,
|
|
182
214
|
});
|
|
183
215
|
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
184
216
|
await interaction.editReply({ content: 'No agents available' });
|
|
@@ -259,19 +291,26 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
259
291
|
return;
|
|
260
292
|
}
|
|
261
293
|
try {
|
|
294
|
+
const previousAgent = await getCurrentAgentInfo({
|
|
295
|
+
sessionId: context.sessionId,
|
|
296
|
+
channelId: context.channelId,
|
|
297
|
+
});
|
|
298
|
+
const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
|
|
262
299
|
await setAgentForContext({ context, agentName: selectedAgent });
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
const modelInfo = await resolveAgentModelInfo({
|
|
301
|
+
context,
|
|
302
|
+
agentName: selectedAgent,
|
|
303
|
+
});
|
|
304
|
+
const scope = context.isThread && context.sessionId ? 'session' : 'channel';
|
|
305
|
+
await interaction.editReply({
|
|
306
|
+
content: formatAgentPreferenceReply({
|
|
307
|
+
agentName: selectedAgent,
|
|
308
|
+
previousAgentName,
|
|
309
|
+
modelInfo,
|
|
310
|
+
scope,
|
|
311
|
+
}),
|
|
312
|
+
components: [],
|
|
313
|
+
});
|
|
275
314
|
pendingAgentContexts.delete(contextHash);
|
|
276
315
|
}
|
|
277
316
|
catch (error) {
|
|
@@ -317,46 +356,20 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
317
356
|
channelId: context.channelId,
|
|
318
357
|
});
|
|
319
358
|
const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
|
|
320
|
-
if (previousAgentName === resolvedAgentName) {
|
|
321
|
-
await command.editReply({
|
|
322
|
-
content: `Already using **${resolvedAgentName}** agent`,
|
|
323
|
-
});
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
// Set the agent preference in DB for this context.
|
|
327
359
|
await setAgentForContext({ context, agentName: resolvedAgentName });
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
sessionId: context.sessionId,
|
|
342
|
-
channelId: context.channelId,
|
|
343
|
-
appId,
|
|
344
|
-
agentPreference: resolvedAgentName,
|
|
345
|
-
getClient,
|
|
346
|
-
directory: context.dir,
|
|
347
|
-
});
|
|
348
|
-
})();
|
|
349
|
-
const modelText = modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`;
|
|
350
|
-
if (context.isThread && context.sessionId) {
|
|
351
|
-
await command.editReply({
|
|
352
|
-
content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
await command.editReply({
|
|
357
|
-
content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
+
const modelInfo = await resolveAgentModelInfo({
|
|
361
|
+
context,
|
|
362
|
+
agentName: resolvedAgentName,
|
|
363
|
+
});
|
|
364
|
+
const scope = context.isThread && context.sessionId ? 'session' : 'channel';
|
|
365
|
+
await command.editReply({
|
|
366
|
+
content: formatAgentPreferenceReply({
|
|
367
|
+
agentName: resolvedAgentName,
|
|
368
|
+
previousAgentName,
|
|
369
|
+
modelInfo,
|
|
370
|
+
scope,
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
360
373
|
}
|
|
361
374
|
catch (error) {
|
|
362
375
|
agentLogger.error('Error in quick agent command:', error);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Cross-menu state: Discord doesn't expose already-selected values on sibling
|
|
7
7
|
// select menus in the same message. We track partial selections in the context
|
|
8
8
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
9
|
-
import { StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
12
12
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -39,6 +39,22 @@ function formatSourceLabel(info) {
|
|
|
39
39
|
return 'none';
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/** /model-variant slash command entrypoint — defers reply then delegates to the shared picker. */
|
|
43
|
+
export async function handleModelVariantCommand({ interaction, appId, }) {
|
|
44
|
+
await interaction.deferReply();
|
|
45
|
+
const channel = interaction.channel;
|
|
46
|
+
if (!channel) {
|
|
47
|
+
await interaction.editReply({
|
|
48
|
+
content: 'This command can only be used in a channel',
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await showModelVariantPicker({
|
|
53
|
+
channel,
|
|
54
|
+
appId,
|
|
55
|
+
editReply: (options) => interaction.editReply(options),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
42
58
|
export async function showModelVariantPicker({ channel, appId, editReply, }) {
|
|
43
59
|
const isThread = [
|
|
44
60
|
ChannelType.PublicThread,
|
package/dist/commands/model.js
CHANGED
|
@@ -37,6 +37,24 @@ function setModelContext(contextHash, context) {
|
|
|
37
37
|
pendingModelContexts.delete(contextHash);
|
|
38
38
|
}, MODEL_CONTEXT_TTL_MS).unref();
|
|
39
39
|
}
|
|
40
|
+
export function formatModelSource({ type, agentName, }) {
|
|
41
|
+
switch (type) {
|
|
42
|
+
case 'session':
|
|
43
|
+
return 'session override';
|
|
44
|
+
case 'agent':
|
|
45
|
+
return `agent "${agentName}"`;
|
|
46
|
+
case 'channel':
|
|
47
|
+
return 'channel override';
|
|
48
|
+
case 'global':
|
|
49
|
+
return 'global default';
|
|
50
|
+
case 'opencode-config':
|
|
51
|
+
case 'opencode-recent':
|
|
52
|
+
case 'opencode-provider-default':
|
|
53
|
+
return 'opencode default';
|
|
54
|
+
default:
|
|
55
|
+
return 'none';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
40
58
|
function parseModelId(modelString) {
|
|
41
59
|
const [providerID, ...modelParts] = modelString.split('/');
|
|
42
60
|
const modelID = modelParts.join('/');
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI commands for multi-provider OAuth account management.
|
|
3
3
|
* Mounted via goke .use() in cli.ts under the `multioauth` namespace.
|
|
4
|
-
* Manages Anthropic and
|
|
4
|
+
* Manages Anthropic, OpenAI, and xAI OAuth account rotation pools.
|
|
5
5
|
*/
|
|
6
6
|
import { goke } from 'goke';
|
|
7
7
|
import { accountLabel as anthropicAccountLabel, accountsFilePath as anthropicAccountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore as loadAnthropicAccountStore, removeAccount as removeAnthropicAccount, } from '../anthropic-auth-state.js';
|
|
8
8
|
import { accountLabel as openaiAccountLabel, openaiAccountsFilePath, getCurrentOpenAIAccount, loadOpenAIAccountStore, removeOpenAIAccount, saveOpenAIAccountStore, } from '../openai-auth-state.js';
|
|
9
|
+
import { accountLabel as xaiAccountLabel, xaiAccountsFilePath, getCurrentXAIAccount, loadXAIAccountStore, removeXAIAccount, saveXAIAccountStore, } from '../xai-auth-state.js';
|
|
9
10
|
const EXIT_NO_RESTART = 64;
|
|
10
11
|
function resolveAccountIndex(indexOrEmail, accounts) {
|
|
11
12
|
const value = Number(indexOrEmail);
|
|
@@ -25,6 +26,7 @@ multioauth
|
|
|
25
26
|
.action(async () => {
|
|
26
27
|
const anthropicStore = await loadAnthropicAccountStore();
|
|
27
28
|
const openaiStore = await loadOpenAIAccountStore();
|
|
29
|
+
const xaiStore = await loadXAIAccountStore();
|
|
28
30
|
console.log('Anthropic OAuth accounts:');
|
|
29
31
|
if (anthropicStore.accounts.length === 0) {
|
|
30
32
|
console.log(' (none)');
|
|
@@ -46,6 +48,17 @@ multioauth
|
|
|
46
48
|
console.log(` ${active} ${index + 1}. ${openaiAccountLabel(account)}`);
|
|
47
49
|
});
|
|
48
50
|
}
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log('xAI OAuth accounts:');
|
|
53
|
+
if (xaiStore.accounts.length === 0) {
|
|
54
|
+
console.log(' (none)');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
xaiStore.accounts.forEach((account, index) => {
|
|
58
|
+
const active = index === xaiStore.activeIndex ? '*' : ' ';
|
|
59
|
+
console.log(` ${active} ${index + 1}. ${xaiAccountLabel(account)}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
49
62
|
process.exit(0);
|
|
50
63
|
});
|
|
51
64
|
// --- Anthropic subcommands ---
|
|
@@ -198,6 +211,9 @@ multioauth
|
|
|
198
211
|
const json = (await response.json());
|
|
199
212
|
accessToken = json.access_token;
|
|
200
213
|
account.access = accessToken;
|
|
214
|
+
// OpenAI can rotate refresh tokens; store the new one if returned
|
|
215
|
+
if (json.refresh_token)
|
|
216
|
+
account.refresh = json.refresh_token;
|
|
201
217
|
account.expires = Date.now() + (json.expires_in ?? 3600) * 1000;
|
|
202
218
|
}
|
|
203
219
|
catch {
|
|
@@ -253,4 +269,156 @@ multioauth
|
|
|
253
269
|
await saveOpenAIAccountStore(store);
|
|
254
270
|
process.exit(0);
|
|
255
271
|
});
|
|
272
|
+
// --- xAI subcommands ---
|
|
273
|
+
multioauth
|
|
274
|
+
.command('multioauth xai list', 'List stored xAI OAuth accounts used for automatic rotation')
|
|
275
|
+
.action(async () => {
|
|
276
|
+
const store = await loadXAIAccountStore();
|
|
277
|
+
console.log(`Store: ${xaiAccountsFilePath()}`);
|
|
278
|
+
if (store.accounts.length === 0) {
|
|
279
|
+
console.log('No xAI OAuth accounts configured.');
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
store.accounts.forEach((account, index) => {
|
|
283
|
+
const active = index === store.activeIndex ? '*' : ' ';
|
|
284
|
+
console.log(`${active} ${index + 1}. ${xaiAccountLabel(account)}`);
|
|
285
|
+
});
|
|
286
|
+
process.exit(0);
|
|
287
|
+
});
|
|
288
|
+
multioauth
|
|
289
|
+
.command('multioauth xai current', 'Show the current xAI OAuth account being used')
|
|
290
|
+
.action(async () => {
|
|
291
|
+
const current = await getCurrentXAIAccount();
|
|
292
|
+
console.log(`Store: ${xaiAccountsFilePath()}`);
|
|
293
|
+
console.log(`Auth: ${authFilePath()}`);
|
|
294
|
+
if (!current) {
|
|
295
|
+
console.log('No active xAI OAuth account configured.');
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
const lines = [];
|
|
299
|
+
lines.push(`Current: ${xaiAccountLabel(current.account || current.auth, current.index)}`);
|
|
300
|
+
if (current.account?.email) {
|
|
301
|
+
lines.push(`Email: ${current.account.email}`);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
lines.push('Email: unavailable');
|
|
305
|
+
}
|
|
306
|
+
if (current.account?.accountId) {
|
|
307
|
+
lines.push(`Account ID: ${current.account.accountId}`);
|
|
308
|
+
}
|
|
309
|
+
if (!current.account) {
|
|
310
|
+
lines.push('Rotation pool entry: not found');
|
|
311
|
+
}
|
|
312
|
+
console.log(lines.join('\n'));
|
|
313
|
+
process.exit(0);
|
|
314
|
+
});
|
|
315
|
+
multioauth
|
|
316
|
+
.command('multioauth xai remove <indexOrEmail>', 'Remove an xAI OAuth account from the rotation pool')
|
|
317
|
+
.action(async (indexOrEmail) => {
|
|
318
|
+
const store = await loadXAIAccountStore();
|
|
319
|
+
const resolvedIndex = resolveAccountIndex(indexOrEmail, store.accounts);
|
|
320
|
+
if (resolvedIndex < 0) {
|
|
321
|
+
console.error('Usage: kimaki multioauth xai remove <index-or-email>');
|
|
322
|
+
process.exit(EXIT_NO_RESTART);
|
|
323
|
+
}
|
|
324
|
+
const removed = store.accounts[resolvedIndex];
|
|
325
|
+
await removeXAIAccount(resolvedIndex);
|
|
326
|
+
console.log(`Removed xAI account ${removed ? xaiAccountLabel(removed, resolvedIndex) : indexOrEmail}`);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
});
|
|
329
|
+
multioauth
|
|
330
|
+
.command('multioauth xai check', 'Test all xAI OAuth accounts for usage limits')
|
|
331
|
+
.action(async () => {
|
|
332
|
+
const store = await loadXAIAccountStore();
|
|
333
|
+
if (store.accounts.length === 0) {
|
|
334
|
+
console.log('No xAI OAuth accounts configured.');
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
console.log('Checking usage limits for all xAI accounts...\n');
|
|
338
|
+
for (let i = 0; i < store.accounts.length; i++) {
|
|
339
|
+
const account = store.accounts[i];
|
|
340
|
+
if (!account)
|
|
341
|
+
continue;
|
|
342
|
+
const marker = i === store.activeIndex ? '*' : ' ';
|
|
343
|
+
const label = account.email ?? account.accountId ?? 'unknown';
|
|
344
|
+
process.stdout.write(`${marker} ${i + 1}. ${label}: `);
|
|
345
|
+
// Refresh token if expired
|
|
346
|
+
let accessToken = account.access;
|
|
347
|
+
if (account.expires < Date.now()) {
|
|
348
|
+
try {
|
|
349
|
+
const response = await fetch('https://auth.x.ai/oauth2/token', {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
352
|
+
body: new URLSearchParams({
|
|
353
|
+
grant_type: 'refresh_token',
|
|
354
|
+
refresh_token: account.refresh,
|
|
355
|
+
client_id: 'b1a00492-073a-47ea-816f-4c329264a828',
|
|
356
|
+
}).toString(),
|
|
357
|
+
});
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
console.log('ERROR - Token expired, refresh failed');
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const json = (await response.json());
|
|
363
|
+
accessToken = json.access_token;
|
|
364
|
+
account.access = accessToken;
|
|
365
|
+
// xAI can rotate refresh tokens; store the new one if returned
|
|
366
|
+
if (json.refresh_token)
|
|
367
|
+
account.refresh = json.refresh_token;
|
|
368
|
+
account.expires = Date.now() + (json.expires_in ?? 3600) * 1000;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
console.log('ERROR - Token expired, refresh failed');
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const response = await fetch('https://api.x.ai/v1/chat/completions', {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: {
|
|
379
|
+
'Content-Type': 'application/json',
|
|
380
|
+
Authorization: `Bearer ${accessToken}`,
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
model: 'grok-3-mini',
|
|
384
|
+
max_tokens: 1,
|
|
385
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
if (response.ok) {
|
|
389
|
+
console.log('OK');
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const text = await response.text();
|
|
393
|
+
const isLimited = text.includes('usage limit') ||
|
|
394
|
+
text.includes('rate limit') ||
|
|
395
|
+
text.includes('usage_limit') ||
|
|
396
|
+
text.includes('balance exhausted') ||
|
|
397
|
+
response.status === 429 ||
|
|
398
|
+
response.status === 402;
|
|
399
|
+
if (isLimited) {
|
|
400
|
+
let resetInfo;
|
|
401
|
+
try {
|
|
402
|
+
const json = JSON.parse(text);
|
|
403
|
+
const match = json.error?.message?.match(/try again (after|in) ([^.]+)/i);
|
|
404
|
+
if (match)
|
|
405
|
+
resetInfo = match[2];
|
|
406
|
+
}
|
|
407
|
+
catch { }
|
|
408
|
+
console.log(`LIMITED${resetInfo ? ` (resets ${resetInfo})` : ''}`);
|
|
409
|
+
}
|
|
410
|
+
else if (response.status === 401) {
|
|
411
|
+
console.log('ERROR - Auth failed (token invalid)');
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
console.log(`ERROR - ${response.status}: ${text.slice(0, 100)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
console.log(`ERROR - ${err instanceof Error ? err.message : String(err)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await saveXAIAccountStore(store);
|
|
422
|
+
process.exit(0);
|
|
423
|
+
});
|
|
256
424
|
export default multioauth;
|
package/dist/commands/queue.js
CHANGED
|
@@ -114,6 +114,70 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
114
114
|
flags: SILENT_MESSAGE_FLAGS,
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
export async function handleClearQueueCommand({ command, }) {
|
|
118
|
+
const channel = command.channel;
|
|
119
|
+
const position = command.options.getInteger('position') ?? undefined;
|
|
120
|
+
if (!channel) {
|
|
121
|
+
await command.reply({
|
|
122
|
+
content: 'This command can only be used in a channel',
|
|
123
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const isThread = [
|
|
128
|
+
ChannelType.PublicThread,
|
|
129
|
+
ChannelType.PrivateThread,
|
|
130
|
+
ChannelType.AnnouncementThread,
|
|
131
|
+
].includes(channel.type);
|
|
132
|
+
if (!isThread) {
|
|
133
|
+
await command.reply({
|
|
134
|
+
content: 'This command can only be used in a thread',
|
|
135
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const runtime = getRuntime(channel.id);
|
|
140
|
+
const queueLength = runtime?.getQueueLength() ?? 0;
|
|
141
|
+
if (queueLength === 0) {
|
|
142
|
+
await command.reply({
|
|
143
|
+
content: 'No messages in queue',
|
|
144
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (position !== undefined) {
|
|
149
|
+
const removed = runtime?.removeQueuePosition(position);
|
|
150
|
+
if (!removed) {
|
|
151
|
+
await command.reply({
|
|
152
|
+
content: `No queued message at position ${position}`,
|
|
153
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await command.reply({
|
|
158
|
+
content: `Cleared queued message at position ${position}`,
|
|
159
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
160
|
+
});
|
|
161
|
+
logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const cleared = runtime?.clearQueue() ?? [];
|
|
165
|
+
const lines = cleared.map((item, i) => {
|
|
166
|
+
const label = item.command
|
|
167
|
+
? `/${item.command.name}`
|
|
168
|
+
: item.prompt;
|
|
169
|
+
return `${i + 1}. ${label}`;
|
|
170
|
+
});
|
|
171
|
+
let list = lines.join('\n');
|
|
172
|
+
if (list.length > 600) {
|
|
173
|
+
list = list.slice(0, 597) + '...';
|
|
174
|
+
}
|
|
175
|
+
await command.reply({
|
|
176
|
+
content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
|
|
177
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
178
|
+
});
|
|
179
|
+
logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
|
|
180
|
+
}
|
|
117
181
|
export async function handleQueueCommandCommand({ command, appId, }) {
|
|
118
182
|
const commandName = command.options.getString('command', true);
|
|
119
183
|
const args = command.options.getString('arguments') || '';
|
package/dist/commands/tasks.js
CHANGED
|
@@ -222,6 +222,12 @@ async function handleRunTaskAction({ interaction, taskId, showAll, }) {
|
|
|
222
222
|
if (result.kind === 'skipped') {
|
|
223
223
|
return `Task #${taskId} is already running or was claimed elsewhere.`;
|
|
224
224
|
}
|
|
225
|
+
if (result.kind === 'condition-not-met') {
|
|
226
|
+
return `Task #${taskId} pre-run condition did not pass.`;
|
|
227
|
+
}
|
|
228
|
+
if (result.kind === 'concurrency-blocked') {
|
|
229
|
+
return `Task #${taskId} still has an active session.`;
|
|
230
|
+
}
|
|
225
231
|
if (result.kind === 'failed') {
|
|
226
232
|
return `Task #${taskId} failed: ${result.error.message}`;
|
|
227
233
|
}
|
|
@@ -8,27 +8,9 @@ import * as schema from '../schema.js';
|
|
|
8
8
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
9
9
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
10
10
|
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
|
-
import { getCurrentModelInfo } from './model.js';
|
|
11
|
+
import { formatModelSource, getCurrentModelInfo } from './model.js';
|
|
12
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
13
|
const unsetModelLogger = createLogger(LogPrefix.MODEL);
|
|
14
|
-
function formatModelSource(type, agentName) {
|
|
15
|
-
switch (type) {
|
|
16
|
-
case 'session':
|
|
17
|
-
return 'session override';
|
|
18
|
-
case 'agent':
|
|
19
|
-
return `agent "${agentName}"`;
|
|
20
|
-
case 'channel':
|
|
21
|
-
return 'channel override';
|
|
22
|
-
case 'global':
|
|
23
|
-
return 'global default';
|
|
24
|
-
case 'opencode-config':
|
|
25
|
-
case 'opencode-recent':
|
|
26
|
-
case 'opencode-provider-default':
|
|
27
|
-
return 'opencode default';
|
|
28
|
-
default:
|
|
29
|
-
return 'none';
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
14
|
/**
|
|
33
15
|
* Clear the nearest model override for the current channel/thread.
|
|
34
16
|
* In thread: clears session override if exists, otherwise channel override.
|
|
@@ -110,7 +92,10 @@ export async function clearModelOverride({ channel, appId, editReply, }) {
|
|
|
110
92
|
newModelText =
|
|
111
93
|
newModelInfo.type === 'none'
|
|
112
94
|
? 'none'
|
|
113
|
-
: `\`${newModelInfo.model}\` (${formatModelSource(
|
|
95
|
+
: `\`${newModelInfo.model}\` (${formatModelSource({
|
|
96
|
+
type: newModelInfo.type,
|
|
97
|
+
agentName,
|
|
98
|
+
})})`;
|
|
114
99
|
}
|
|
115
100
|
// Check if there's a running request and abort+retry with new model (only for session changes in threads)
|
|
116
101
|
let retried = false;
|