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/system-message.js
CHANGED
|
@@ -294,7 +294,8 @@ Use \`--send-at\` to schedule a one-time or recurring task:
|
|
|
294
294
|
kimaki send --channel ${channelId} --prompt "Reminder: review open PRs" --send-at "2026-03-01T09:00:00Z"
|
|
295
295
|
kimaki send --channel ${channelId} --prompt "Run weekly test suite and summarize failures" --send-at "0 9 * * 1"
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
|
|
298
|
+
When the user specifies a time without a timezone, ask them to confirm their timezone or the UTC equivalent. Never guess the user's timezone.
|
|
298
299
|
|
|
299
300
|
\`--send-at\` supports the same useful options for new threads:
|
|
300
301
|
- \`--notify-only\` to create a reminder thread without auto-starting a session
|
|
@@ -318,6 +319,7 @@ Notification strategy for scheduled tasks:
|
|
|
318
319
|
Manage scheduled tasks with:
|
|
319
320
|
|
|
320
321
|
kimaki task list
|
|
322
|
+
kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"]
|
|
321
323
|
kimaki task delete <id>
|
|
322
324
|
|
|
323
325
|
\`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
|
package/dist/task-runner.js
CHANGED
|
@@ -5,7 +5,7 @@ import yaml from 'js-yaml';
|
|
|
5
5
|
import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, markScheduledTaskOneShotCompleted, recoverStaleRunningScheduledTasks, } from './database.js';
|
|
6
6
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
7
7
|
import { notifyError } from './sentry.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
9
9
|
const taskLogger = createLogger(LogPrefix.TASK);
|
|
10
10
|
function isRecord(value) {
|
|
11
11
|
return typeof value === 'object' && value !== null;
|
|
@@ -28,6 +28,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
28
28
|
...(payload.model ? { model: payload.model } : {}),
|
|
29
29
|
...(payload.username ? { username: payload.username } : {}),
|
|
30
30
|
...(payload.userId ? { userId: payload.userId } : {}),
|
|
31
|
+
...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
|
|
31
32
|
};
|
|
32
33
|
const embed = [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }];
|
|
33
34
|
const prefixedPrompt = `» **kimaki-cli:** ${payload.prompt}`;
|
|
@@ -59,6 +60,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
59
60
|
...(payload.model ? { model: payload.model } : {}),
|
|
60
61
|
...(payload.username ? { username: payload.username } : {}),
|
|
61
62
|
...(payload.userId ? { userId: payload.userId } : {}),
|
|
63
|
+
...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
|
|
62
64
|
};
|
|
63
65
|
const embeds = marker
|
|
64
66
|
? [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
|
|
@@ -151,7 +153,8 @@ async function finalizeSuccessfulTask({ task, completedAt, }) {
|
|
|
151
153
|
});
|
|
152
154
|
return;
|
|
153
155
|
}
|
|
154
|
-
|
|
156
|
+
// Use stored timezone, falling back to UTC (not machine local) for consistency
|
|
157
|
+
const timezone = task.timezone || 'UTC';
|
|
155
158
|
const nextRunResult = getNextCronRun({
|
|
156
159
|
cronExpr: task.cron_expr,
|
|
157
160
|
timezone,
|
|
@@ -173,7 +176,8 @@ async function finalizeSuccessfulTask({ task, completedAt, }) {
|
|
|
173
176
|
}
|
|
174
177
|
async function finalizeFailedTask({ task, failedAt, error, }) {
|
|
175
178
|
if (task.schedule_kind === 'cron' && task.cron_expr) {
|
|
176
|
-
|
|
179
|
+
// Use stored timezone, falling back to UTC (not machine local) for consistency
|
|
180
|
+
const timezone = task.timezone || 'UTC';
|
|
177
181
|
const nextRunResult = getNextCronRun({
|
|
178
182
|
cronExpr: task.cron_expr,
|
|
179
183
|
timezone,
|
package/dist/task-schedule.js
CHANGED
|
@@ -124,6 +124,14 @@ function asString(value) {
|
|
|
124
124
|
}
|
|
125
125
|
return value;
|
|
126
126
|
}
|
|
127
|
+
function asStringArray(value) {
|
|
128
|
+
if (!Array.isArray(value)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return value.filter((v) => {
|
|
132
|
+
return typeof v === 'string';
|
|
133
|
+
});
|
|
134
|
+
}
|
|
127
135
|
export function parseScheduledTaskPayload(payloadJson) {
|
|
128
136
|
const parsed = errore.try({
|
|
129
137
|
try: () => {
|
|
@@ -147,6 +155,7 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
147
155
|
const model = asString(parsed.model);
|
|
148
156
|
const username = asString(parsed.username);
|
|
149
157
|
const userId = asString(parsed.userId);
|
|
158
|
+
const permissions = asStringArray(parsed.permissions);
|
|
150
159
|
if (!threadId || !prompt) {
|
|
151
160
|
return new Error('Thread task payload requires threadId and prompt');
|
|
152
161
|
}
|
|
@@ -158,6 +167,7 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
158
167
|
model,
|
|
159
168
|
username,
|
|
160
169
|
userId,
|
|
170
|
+
permissions,
|
|
161
171
|
};
|
|
162
172
|
}
|
|
163
173
|
if (kind === 'channel') {
|
|
@@ -171,6 +181,7 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
171
181
|
const model = asString(parsed.model);
|
|
172
182
|
const username = asString(parsed.username);
|
|
173
183
|
const userId = asString(parsed.userId);
|
|
184
|
+
const permissions = asStringArray(parsed.permissions);
|
|
174
185
|
if (!channelId || !prompt) {
|
|
175
186
|
return new Error('Channel task payload requires channelId and prompt');
|
|
176
187
|
}
|
|
@@ -185,6 +196,7 @@ export function parseScheduledTaskPayload(payloadJson) {
|
|
|
185
196
|
model,
|
|
186
197
|
username,
|
|
187
198
|
userId,
|
|
199
|
+
permissions,
|
|
188
200
|
};
|
|
189
201
|
}
|
|
190
202
|
return new Error('Task payload has unknown kind');
|
|
@@ -435,11 +435,20 @@ e2eTest('thread message queue ordering', () => {
|
|
|
435
435
|
},
|
|
436
436
|
});
|
|
437
437
|
const th = discord.thread(thread.id);
|
|
438
|
-
// Wait for the first bot reply so
|
|
438
|
+
// Wait for the first bot reply AND its footer so the first response
|
|
439
|
+
// cycle is fully complete before sending follow-ups. Without this,
|
|
440
|
+
// the footer for "one" can still be in-flight when the snapshot runs.
|
|
439
441
|
const firstReply = await th.waitForBotReply({
|
|
440
442
|
timeout: 4_000,
|
|
441
443
|
});
|
|
442
444
|
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
445
|
+
await waitForFooterMessage({
|
|
446
|
+
discord,
|
|
447
|
+
threadId: thread.id,
|
|
448
|
+
timeout: 4_000,
|
|
449
|
+
afterMessageIncludes: 'one',
|
|
450
|
+
afterAuthorId: TEST_USER_ID,
|
|
451
|
+
});
|
|
443
452
|
// Snapshot bot message count before sending follow-ups
|
|
444
453
|
const before = await th.getMessages();
|
|
445
454
|
const beforeBotCount = before.filter((m) => {
|
|
@@ -478,10 +487,13 @@ e2eTest('thread message queue ordering', () => {
|
|
|
478
487
|
Reply with exactly: one
|
|
479
488
|
--- from: assistant (TestBot)
|
|
480
489
|
⬥ ok
|
|
490
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
481
491
|
--- from: user (queue-tester)
|
|
482
492
|
Reply with exactly: two
|
|
483
493
|
Reply with exactly: three
|
|
484
494
|
--- from: assistant (TestBot)
|
|
495
|
+
⬥ ok
|
|
496
|
+
⬥ ok
|
|
485
497
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
486
498
|
`);
|
|
487
499
|
const userThreeIndex = after.findIndex((message) => {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// E2e test for /undo command.
|
|
2
|
+
// Validates that:
|
|
3
|
+
// 1. After /undo, session.revert state is set (files reverted, revert boundary marked)
|
|
4
|
+
// 2. Messages are NOT deleted yet (they stay until next prompt cleans them up)
|
|
5
|
+
// 3. On the next user message, reverted messages are cleaned up by OpenCode's
|
|
6
|
+
// SessionRevert.cleanup() and the model only sees pre-revert messages
|
|
7
|
+
//
|
|
8
|
+
// This matches the OpenCode TUI behavior (use-session-commands.tsx):
|
|
9
|
+
// - Pass the user message ID (not assistant ID)
|
|
10
|
+
// - Don't delete messages — just mark session as reverted
|
|
11
|
+
// - Cleanup happens automatically on next promptAsync()
|
|
12
|
+
//
|
|
13
|
+
// Uses opencode-deterministic-provider (no real LLM calls).
|
|
14
|
+
// Poll timeouts: 4s max, 100ms interval.
|
|
15
|
+
import { describe, test, expect } from 'vitest';
|
|
16
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
17
|
+
import { waitForFooterMessage } from './test-utils.js';
|
|
18
|
+
import { getThreadSession } from './database.js';
|
|
19
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
20
|
+
const TEXT_CHANNEL_ID = '200000000000001200';
|
|
21
|
+
const e2eTest = describe;
|
|
22
|
+
e2eTest('/undo sets revert state and cleans up on next prompt', () => {
|
|
23
|
+
const ctx = setupQueueAdvancedSuite({
|
|
24
|
+
channelId: TEXT_CHANNEL_ID,
|
|
25
|
+
channelName: 'qa-undo-e2e',
|
|
26
|
+
dirName: 'qa-undo-e2e',
|
|
27
|
+
username: 'undo-tester',
|
|
28
|
+
});
|
|
29
|
+
test('undo sets revert state, next message cleans up reverted messages', async () => {
|
|
30
|
+
// 1. Send a message and wait for complete session (footer)
|
|
31
|
+
await ctx.discord
|
|
32
|
+
.channel(TEXT_CHANNEL_ID)
|
|
33
|
+
.user(TEST_USER_ID)
|
|
34
|
+
.sendMessage({
|
|
35
|
+
content: 'Reply with exactly: undo-test-message',
|
|
36
|
+
});
|
|
37
|
+
const thread = await ctx.discord
|
|
38
|
+
.channel(TEXT_CHANNEL_ID)
|
|
39
|
+
.waitForThread({
|
|
40
|
+
timeout: 4_000,
|
|
41
|
+
predicate: (t) => {
|
|
42
|
+
return t.name === 'Reply with exactly: undo-test-message';
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const th = ctx.discord.thread(thread.id);
|
|
46
|
+
await th.waitForBotReply({ timeout: 4_000 });
|
|
47
|
+
await waitForFooterMessage({
|
|
48
|
+
discord: ctx.discord,
|
|
49
|
+
threadId: thread.id,
|
|
50
|
+
timeout: 4_000,
|
|
51
|
+
});
|
|
52
|
+
// 2. Get session ID and verify it has messages
|
|
53
|
+
const sessionId = await getThreadSession(thread.id);
|
|
54
|
+
expect(sessionId).toBeTruthy();
|
|
55
|
+
const getClient = await initializeOpencodeForDirectory(ctx.directories.projectDirectory);
|
|
56
|
+
if (getClient instanceof Error) {
|
|
57
|
+
throw getClient;
|
|
58
|
+
}
|
|
59
|
+
const beforeMessages = await getClient().session.messages({
|
|
60
|
+
sessionID: sessionId,
|
|
61
|
+
directory: ctx.directories.projectDirectory,
|
|
62
|
+
});
|
|
63
|
+
const beforeCount = (beforeMessages.data || []).length;
|
|
64
|
+
expect(beforeCount).toBeGreaterThan(0);
|
|
65
|
+
const beforeUserMessages = (beforeMessages.data || []).filter((m) => {
|
|
66
|
+
return m.info.role === 'user';
|
|
67
|
+
});
|
|
68
|
+
const beforeAssistantMessages = (beforeMessages.data || []).filter((m) => {
|
|
69
|
+
return m.info.role === 'assistant';
|
|
70
|
+
});
|
|
71
|
+
expect(beforeUserMessages.length).toBeGreaterThan(0);
|
|
72
|
+
expect(beforeAssistantMessages.length).toBeGreaterThan(0);
|
|
73
|
+
// Verify no revert state yet
|
|
74
|
+
const beforeSession = await getClient().session.get({
|
|
75
|
+
sessionID: sessionId,
|
|
76
|
+
});
|
|
77
|
+
expect(beforeSession.data?.revert).toBeFalsy();
|
|
78
|
+
// 3. Run /undo command
|
|
79
|
+
const { id: undoInteractionId } = await th
|
|
80
|
+
.user(TEST_USER_ID)
|
|
81
|
+
.runSlashCommand({ name: 'undo' });
|
|
82
|
+
const undoAck = await th.waitForInteractionAck({
|
|
83
|
+
interactionId: undoInteractionId,
|
|
84
|
+
timeout: 4_000,
|
|
85
|
+
});
|
|
86
|
+
expect(undoAck).toBeDefined();
|
|
87
|
+
// Wait for the undo reply to appear (deferred reply gets edited)
|
|
88
|
+
if (undoAck.messageId) {
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
while (Date.now() - start < 4_000) {
|
|
91
|
+
const messages = await th.getMessages();
|
|
92
|
+
const undoMessage = messages.find((m) => {
|
|
93
|
+
return m.id === undoAck.messageId;
|
|
94
|
+
});
|
|
95
|
+
if (undoMessage && undoMessage.content.length > 0) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
await new Promise((r) => {
|
|
99
|
+
setTimeout(r, 100);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 4. Verify session now has revert state set
|
|
104
|
+
const afterSession = await getClient().session.get({
|
|
105
|
+
sessionID: sessionId,
|
|
106
|
+
});
|
|
107
|
+
expect(afterSession.data?.revert).toBeTruthy();
|
|
108
|
+
expect(afterSession.data?.revert?.messageID).toBeTruthy();
|
|
109
|
+
// Messages should still exist (not deleted — cleanup happens on next prompt)
|
|
110
|
+
const afterMessages = await getClient().session.messages({
|
|
111
|
+
sessionID: sessionId,
|
|
112
|
+
directory: ctx.directories.projectDirectory,
|
|
113
|
+
});
|
|
114
|
+
expect((afterMessages.data || []).length).toBe(beforeCount);
|
|
115
|
+
// 5. Send a new message — this triggers SessionRevert.cleanup()
|
|
116
|
+
// which removes reverted messages before processing the new prompt
|
|
117
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
118
|
+
content: 'Reply with exactly: after-undo-message',
|
|
119
|
+
});
|
|
120
|
+
await waitForFooterMessage({
|
|
121
|
+
discord: ctx.discord,
|
|
122
|
+
threadId: thread.id,
|
|
123
|
+
timeout: 4_000,
|
|
124
|
+
afterMessageIncludes: 'after-undo-message',
|
|
125
|
+
});
|
|
126
|
+
// 6. Verify reverted messages were cleaned up
|
|
127
|
+
const finalMessages = await getClient().session.messages({
|
|
128
|
+
sessionID: sessionId,
|
|
129
|
+
directory: ctx.directories.projectDirectory,
|
|
130
|
+
});
|
|
131
|
+
const finalAssistantMessages = (finalMessages.data || []).filter((m) => {
|
|
132
|
+
return m.info.role === 'assistant';
|
|
133
|
+
});
|
|
134
|
+
// The original assistant message should have been cleaned up,
|
|
135
|
+
// only the new one (from after-undo-message) should remain
|
|
136
|
+
const originalAssistantStillExists = finalAssistantMessages.some((m) => {
|
|
137
|
+
return m.parts.some((p) => {
|
|
138
|
+
return p.type === 'text' && 'text' in p && p.text === 'ok';
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// The first "ok" response was reverted and should be cleaned up.
|
|
142
|
+
// The new response for "after-undo-message" should produce a fresh "ok".
|
|
143
|
+
// We verify the total count dropped: the original user+assistant pair
|
|
144
|
+
// was removed, and replaced by just the new user+assistant pair.
|
|
145
|
+
expect(finalAssistantMessages.length).toBeLessThanOrEqual(beforeAssistantMessages.length);
|
|
146
|
+
// Revert state should be cleared after cleanup
|
|
147
|
+
const finalSession = await getClient().session.get({
|
|
148
|
+
sessionID: sessionId,
|
|
149
|
+
});
|
|
150
|
+
expect(finalSession.data?.revert).toBeFalsy();
|
|
151
|
+
// 7. Snapshot the Discord thread
|
|
152
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
153
|
+
"--- from: user (undo-tester)
|
|
154
|
+
Reply with exactly: undo-test-message
|
|
155
|
+
--- from: assistant (TestBot)
|
|
156
|
+
⬥ ok
|
|
157
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
158
|
+
Undone - reverted last assistant message
|
|
159
|
+
--- from: user (undo-tester)
|
|
160
|
+
Reply with exactly: after-undo-message
|
|
161
|
+
--- from: assistant (TestBot)
|
|
162
|
+
⬥ ok
|
|
163
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
164
|
+
`);
|
|
165
|
+
}, 20_000);
|
|
166
|
+
});
|
package/dist/utils.js
CHANGED
|
@@ -47,7 +47,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
|
|
|
47
47
|
}
|
|
48
48
|
export const KIMAKI_GATEWAY_APP_ID = process.env.KIMAKI_GATEWAY_APP_ID || '1477605701202481173';
|
|
49
49
|
export const KIMAKI_WEBSITE_URL = process.env.KIMAKI_WEBSITE_URL || 'https://kimaki.xyz';
|
|
50
|
-
export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientSecret, gatewayCallbackUrl, }) {
|
|
50
|
+
export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientSecret, gatewayCallbackUrl, reachableUrl, }) {
|
|
51
51
|
if (mode !== 'gateway') {
|
|
52
52
|
return generateBotInstallUrl({ clientId: appId });
|
|
53
53
|
}
|
|
@@ -66,6 +66,9 @@ export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientS
|
|
|
66
66
|
if (gatewayCallbackUrl) {
|
|
67
67
|
url.searchParams.set('kimakiCallbackUrl', gatewayCallbackUrl);
|
|
68
68
|
}
|
|
69
|
+
if (reachableUrl) {
|
|
70
|
+
url.searchParams.set('reachableUrl', reachableUrl);
|
|
71
|
+
}
|
|
69
72
|
return url.toString();
|
|
70
73
|
}
|
|
71
74
|
export function deduplicateByKey(arr, keyFn) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Voice attachment detection helpers.
|
|
2
|
+
// Normalizes Discord attachment heuristics for voice-message detection so
|
|
3
|
+
// message routing, transcription, and empty-prompt guards all agree even when
|
|
4
|
+
// Discord omits contentType on uploaded audio attachments.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const VOICE_ATTACHMENT_EXTENSIONS = new Set([
|
|
7
|
+
'.m4a',
|
|
8
|
+
'.mp3',
|
|
9
|
+
'.mp4',
|
|
10
|
+
'.oga',
|
|
11
|
+
'.ogg',
|
|
12
|
+
'.opus',
|
|
13
|
+
'.wav',
|
|
14
|
+
]);
|
|
15
|
+
export function getVoiceAttachmentMatchReason(attachment) {
|
|
16
|
+
const contentType = attachment.contentType?.trim().toLowerCase() || '';
|
|
17
|
+
if (contentType.startsWith('audio/')) {
|
|
18
|
+
return `contentType:${contentType}`;
|
|
19
|
+
}
|
|
20
|
+
if (typeof attachment.duration === 'number' && attachment.duration > 0) {
|
|
21
|
+
return `duration:${attachment.duration}`;
|
|
22
|
+
}
|
|
23
|
+
if (attachment.waveform?.trim()) {
|
|
24
|
+
return 'waveform';
|
|
25
|
+
}
|
|
26
|
+
const extension = path.extname(attachment.name || '').toLowerCase();
|
|
27
|
+
if (VOICE_ATTACHMENT_EXTENSIONS.has(extension)) {
|
|
28
|
+
return `extension:${extension}`;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function isVoiceAttachment(attachment) {
|
|
33
|
+
return getVoiceAttachmentMatchReason(attachment) !== null;
|
|
34
|
+
}
|
package/dist/voice-handler.js
CHANGED
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
// and routes audio to the GenAI worker for real-time voice assistant interactions.
|
|
4
4
|
import * as errore from 'errore';
|
|
5
5
|
import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
|
|
6
|
-
import { exec } from 'node:child_process';
|
|
7
6
|
import fs, { createWriteStream } from 'node:fs';
|
|
8
7
|
import { mkdir } from 'node:fs/promises';
|
|
9
8
|
import path from 'node:path';
|
|
10
|
-
import { promisify } from 'node:util';
|
|
11
9
|
import { Transform } from 'node:stream';
|
|
12
10
|
import * as prism from 'prism-media';
|
|
13
11
|
import dedent from 'string-dedent';
|
|
14
12
|
import { Events, ActionRowBuilder, ButtonBuilder, ButtonStyle, } from 'discord.js';
|
|
15
13
|
import { createGenAIWorker } from './genai-worker-wrapper.js';
|
|
16
14
|
import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, findTextChannelByVoiceChannel, } from './database.js';
|
|
17
|
-
import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
|
|
15
|
+
import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
|
|
18
16
|
import { transcribeAudio } from './voice.js';
|
|
19
17
|
import { FetchError } from './errors.js';
|
|
20
18
|
import { store } from './store.js';
|
|
19
|
+
import { getVoiceAttachmentMatchReason, isVoiceAttachment, } from './voice-attachment.js';
|
|
20
|
+
import { execAsync } from './worktrees.js';
|
|
21
21
|
import { createLogger, LogPrefix } from './logger.js';
|
|
22
22
|
import { notifyError } from './sentry.js';
|
|
23
23
|
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
@@ -195,7 +195,7 @@ export async function setupVoiceHandling({ connection, guildId, channelId, appId
|
|
|
195
195
|
if (textChannel?.isTextBased() && 'send' in textChannel) {
|
|
196
196
|
await textChannel.send({
|
|
197
197
|
content: `⚠️ Voice session error: ${String(error).slice(0, 1900)}`,
|
|
198
|
-
flags:
|
|
198
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
199
199
|
});
|
|
200
200
|
}
|
|
201
201
|
}
|
|
@@ -329,10 +329,11 @@ export async function cleanupVoiceConnection(guildId) {
|
|
|
329
329
|
// Per-thread serialization is handled by ThreadSessionRuntime.enqueueIncoming()
|
|
330
330
|
// via the runtime action queue; no local serialization is needed here.
|
|
331
331
|
export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
|
|
332
|
-
const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment
|
|
332
|
+
const audioAttachment = Array.from(message.attachments.values()).find((attachment) => isVoiceAttachment(attachment));
|
|
333
333
|
if (!audioAttachment)
|
|
334
334
|
return null;
|
|
335
|
-
|
|
335
|
+
const attachmentMatchReason = getVoiceAttachmentMatchReason(audioAttachment);
|
|
336
|
+
voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType || 'no contentType'}, ${attachmentMatchReason || 'unknown reason'})`);
|
|
336
337
|
await sendThreadMessage(thread, '🎤 Transcribing voice message...');
|
|
337
338
|
// Deterministic mode: skip audio download and AI model call entirely,
|
|
338
339
|
// return a canned result after an optional delay. Used by e2e tests to
|
|
@@ -370,7 +371,7 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
370
371
|
});
|
|
371
372
|
if (audioResponse instanceof Error) {
|
|
372
373
|
voiceLogger.error(`Failed to download audio attachment:`, audioResponse.message);
|
|
373
|
-
await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}
|
|
374
|
+
await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
374
375
|
return null;
|
|
375
376
|
}
|
|
376
377
|
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
|
|
@@ -379,7 +380,6 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
379
380
|
if (projectDirectory) {
|
|
380
381
|
try {
|
|
381
382
|
voiceLogger.log(`Getting project file tree from ${projectDirectory}`);
|
|
382
|
-
const execAsync = promisify(exec);
|
|
383
383
|
const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
|
|
384
384
|
cwd: projectDirectory,
|
|
385
385
|
});
|
|
@@ -450,7 +450,9 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
450
450
|
Error: (e) => e.message,
|
|
451
451
|
});
|
|
452
452
|
voiceLogger.error(`Transcription failed:`, transcription);
|
|
453
|
-
await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}
|
|
453
|
+
await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`, {
|
|
454
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
455
|
+
});
|
|
454
456
|
return null;
|
|
455
457
|
}
|
|
456
458
|
const { transcription: text, queueMessage } = transcription;
|
|
@@ -414,6 +414,84 @@ e2eTest('voice message handling', () => {
|
|
|
414
414
|
const assistantTexts = getAssistantTexts(messages);
|
|
415
415
|
expect(assistantTexts.length).toBeGreaterThan(0);
|
|
416
416
|
}, 8_000);
|
|
417
|
+
test('voice attachment without content type still transcribes and avoids empty prompt dispatch', async () => {
|
|
418
|
+
setDeterministicTranscription({
|
|
419
|
+
transcription: 'Investigate the missing content type path',
|
|
420
|
+
queueMessage: false,
|
|
421
|
+
});
|
|
422
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
423
|
+
content: '',
|
|
424
|
+
attachments: [
|
|
425
|
+
{
|
|
426
|
+
id: 'voice-no-content-type',
|
|
427
|
+
filename: 'voice-message.ogg',
|
|
428
|
+
size: 1024,
|
|
429
|
+
url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
|
|
430
|
+
proxy_url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
});
|
|
434
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
435
|
+
timeout: 4_000,
|
|
436
|
+
predicate: (t) => {
|
|
437
|
+
return t.name?.includes('Investigate the missing content type path') ?? false;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
const th = discord.thread(thread.id);
|
|
441
|
+
await waitForBotMessageContaining({
|
|
442
|
+
discord,
|
|
443
|
+
threadId: thread.id,
|
|
444
|
+
userId: TEST_USER_ID,
|
|
445
|
+
text: 'Transcribing voice message',
|
|
446
|
+
timeout: 4_000,
|
|
447
|
+
});
|
|
448
|
+
await waitForBotMessageContaining({
|
|
449
|
+
discord,
|
|
450
|
+
threadId: thread.id,
|
|
451
|
+
userId: TEST_USER_ID,
|
|
452
|
+
text: 'Investigate the missing content type path',
|
|
453
|
+
timeout: 4_000,
|
|
454
|
+
});
|
|
455
|
+
await waitForFooterMessage({
|
|
456
|
+
discord,
|
|
457
|
+
threadId: thread.id,
|
|
458
|
+
timeout: 4_000,
|
|
459
|
+
});
|
|
460
|
+
const finalState = await waitForThreadState({
|
|
461
|
+
threadId: thread.id,
|
|
462
|
+
predicate: (state) => {
|
|
463
|
+
return Boolean(state.sessionId) && state.queueItems.length === 0;
|
|
464
|
+
},
|
|
465
|
+
timeout: 4_000,
|
|
466
|
+
description: 'voice attachment without content type settled',
|
|
467
|
+
});
|
|
468
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
469
|
+
"--- from: user (voice-tester)
|
|
470
|
+
[attachment: voice-message.ogg]
|
|
471
|
+
--- from: assistant (TestBot)
|
|
472
|
+
🎤 Transcribing voice message...
|
|
473
|
+
📝 **Transcribed message:** Investigate the missing content type path
|
|
474
|
+
⬥ session-reply
|
|
475
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
476
|
+
`);
|
|
477
|
+
const messages = await waitForSessionMessages({
|
|
478
|
+
projectDirectory: directories.projectDirectory,
|
|
479
|
+
sessionID: finalState.sessionId,
|
|
480
|
+
timeout: 4_000,
|
|
481
|
+
description: 'voice attachment without content type dispatched once',
|
|
482
|
+
predicate: (all) => {
|
|
483
|
+
const userTexts = getUserTexts(all);
|
|
484
|
+
return userTexts.some((text) => {
|
|
485
|
+
return text.includes('Investigate the missing content type path');
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
const userTexts = getUserTexts(messages);
|
|
490
|
+
expect(userTexts).not.toContain('');
|
|
491
|
+
expect(userTexts.some((text) => {
|
|
492
|
+
return text.includes('Investigate the missing content type path');
|
|
493
|
+
})).toBe(true);
|
|
494
|
+
}, 8_000);
|
|
417
495
|
// ── Test 2: Voice message in thread with idle session ──
|
|
418
496
|
test('voice message in thread with idle session starts new request', async () => {
|
|
419
497
|
// 1. Create a session with a text message first
|
package/dist/voice.test.js
CHANGED
|
@@ -4,6 +4,7 @@ import { describe, test, expect } from 'vitest';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { transcribeAudio, convertOggToWav, extractTranscription, normalizeAudioMediaType, getOpenAIAudioConversionStrategy, } from './voice.js';
|
|
7
|
+
import { getVoiceAttachmentMatchReason, isVoiceAttachment, } from './voice-attachment.js';
|
|
7
8
|
describe('audio media type routing', () => {
|
|
8
9
|
test('normalizes m4a aliases to audio/mp4', () => {
|
|
9
10
|
expect(normalizeAudioMediaType('audio/x-m4a')).toMatchInlineSnapshot('"audio/mp4"');
|
|
@@ -20,6 +21,36 @@ describe('audio media type routing', () => {
|
|
|
20
21
|
expect(getOpenAIAudioConversionStrategy('audio/mpeg')).toMatchInlineSnapshot('"none"');
|
|
21
22
|
});
|
|
22
23
|
});
|
|
24
|
+
describe('voice attachment detection', () => {
|
|
25
|
+
test('detects voice attachments by content type, extension, and waveform metadata', () => {
|
|
26
|
+
expect([
|
|
27
|
+
getVoiceAttachmentMatchReason({
|
|
28
|
+
name: 'voice-message.ogg',
|
|
29
|
+
contentType: 'audio/ogg',
|
|
30
|
+
}),
|
|
31
|
+
getVoiceAttachmentMatchReason({
|
|
32
|
+
name: 'voice-message.ogg',
|
|
33
|
+
contentType: null,
|
|
34
|
+
}),
|
|
35
|
+
getVoiceAttachmentMatchReason({
|
|
36
|
+
name: 'upload.bin',
|
|
37
|
+
contentType: null,
|
|
38
|
+
waveform: 'abc123',
|
|
39
|
+
}),
|
|
40
|
+
isVoiceAttachment({
|
|
41
|
+
name: 'notes.txt',
|
|
42
|
+
contentType: null,
|
|
43
|
+
}),
|
|
44
|
+
]).toMatchInlineSnapshot(`
|
|
45
|
+
[
|
|
46
|
+
"contentType:audio/ogg",
|
|
47
|
+
"extension:.ogg",
|
|
48
|
+
"waveform",
|
|
49
|
+
false,
|
|
50
|
+
]
|
|
51
|
+
`);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
23
54
|
describe('extractTranscription', () => {
|
|
24
55
|
test('extracts transcription from tool call', () => {
|
|
25
56
|
const result = extractTranscription([
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.80",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
"@types/json-schema": "^7.0.15",
|
|
20
20
|
"@types/ms": "^2.1.0",
|
|
21
21
|
"@types/node": "^24.3.0",
|
|
22
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
22
23
|
"eventsource-parser": "^3.0.6",
|
|
24
|
+
"lintcn": "^0.3.0",
|
|
23
25
|
"prisma": "7.4.2",
|
|
24
26
|
"tsx": "^4.20.5",
|
|
25
27
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
+
"db": "^0.0.0",
|
|
26
29
|
"opencode-cached-provider": "^0.0.1",
|
|
27
|
-
"opencode-deterministic-provider": "^0.0.1"
|
|
28
|
-
"db": "^0.0.0"
|
|
30
|
+
"opencode-deterministic-provider": "^0.0.1"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"@ai-sdk/google": "^3.0.30",
|
|
@@ -35,8 +37,9 @@
|
|
|
35
37
|
"@discordjs/voice": "^0.19.0",
|
|
36
38
|
"@google/genai": "^1.34.0",
|
|
37
39
|
"@libsql/client": "^0.15.15",
|
|
38
|
-
"@
|
|
39
|
-
"@opencode-ai/
|
|
40
|
+
"@openauthjs/openauth": "^0.4.3",
|
|
41
|
+
"@opencode-ai/plugin": "^1.2.27",
|
|
42
|
+
"@opencode-ai/sdk": "^1.2.27",
|
|
40
43
|
"@parcel/watcher": "^2.5.6",
|
|
41
44
|
"@prisma/adapter-libsql": "7.4.2",
|
|
42
45
|
"@prisma/client": "7.4.2",
|
|
@@ -54,14 +57,15 @@
|
|
|
54
57
|
"mime": "^4.1.0",
|
|
55
58
|
"picocolors": "^1.1.1",
|
|
56
59
|
"pretty-ms": "^9.3.0",
|
|
60
|
+
"proper-lockfile": "^4.1.2",
|
|
57
61
|
"string-dedent": "^3.0.2",
|
|
58
62
|
"undici": "^7.16.0",
|
|
59
63
|
"ws": "^8.19.0",
|
|
60
64
|
"xdg-basedir": "^5.1.0",
|
|
61
65
|
"zod": "^4.3.6",
|
|
62
66
|
"zustand": "^5.0.11",
|
|
63
|
-
"errore": "^0.14.
|
|
64
|
-
"traforo": "^0.0
|
|
67
|
+
"errore": "^0.14.1",
|
|
68
|
+
"traforo": "^0.1.0"
|
|
65
69
|
},
|
|
66
70
|
"optionalDependencies": {
|
|
67
71
|
"@discordjs/opus": "^0.10.0",
|
|
@@ -81,6 +85,7 @@
|
|
|
81
85
|
"validate-typing-indicator": "doppler run -- tsx scripts/validate-typing-indicator.ts",
|
|
82
86
|
"test:send": "tsx send-test-message.ts",
|
|
83
87
|
"register-commands": "tsx scripts/register-commands.ts",
|
|
88
|
+
"lint": "lintcn lint",
|
|
84
89
|
"format": "oxfmt src",
|
|
85
90
|
"sync-skills": "tsx scripts/sync-skills.ts"
|
|
86
91
|
}
|