kimaki 0.27.0 → 0.28.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 +247 -64
- package/dist/anthropic-account-identity.js +8 -1
- package/dist/anthropic-auth-plugin.js +9 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +6 -1
- package/dist/cli-commands/session.js +139 -82
- package/dist/cli-commands/task.js +21 -14
- package/dist/cli-parsing.test.js +24 -1
- package/dist/cli-runner.js +16 -4
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +158 -24
- package/dist/commands/ask-question.js +2 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/merge-worktree.js +13 -6
- package/dist/commands/model-variant.js +3 -2
- package/dist/commands/model.js +4 -3
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +28 -0
- package/dist/db.js +11 -0
- package/dist/db.test.js +100 -0
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +501 -474
- package/dist/discord-command-registration.js +16 -9
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +84 -0
- package/dist/discord-utils.test.js +135 -1
- package/dist/errors.js +5 -0
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +12 -7
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/logger.js +1 -0
- package/dist/message-formatting.js +163 -44
- package/dist/message-formatting.test.js +473 -13
- package/dist/message-preprocessing.js +105 -1
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +49 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +29 -3
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +5 -5
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-footer.e2e.test.js +29 -32
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
- package/dist/queue-advanced-question.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +20 -20
- package/dist/queue-question-select-drain.e2e.test.js +7 -7
- package/dist/runtime-lifecycle.e2e.test.js +16 -19
- package/dist/schema.js +1 -1
- package/dist/session-handler/event-stream-state.js +124 -0
- package/dist/session-handler/event-stream-state.test.js +322 -1
- package/dist/session-handler/model-utils.js +16 -3
- package/dist/session-handler/model-utils.test.js +103 -3
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +340 -135
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-title-rename.test.js +30 -0
- package/dist/sleep.e2e.test.js +14 -11
- package/dist/system-message.js +45 -12
- package/dist/system-message.test.js +117 -10
- package/dist/task-runner.js +2 -1
- package/dist/task-schedule.js +32 -2
- package/dist/task-schedule.test.js +79 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -54
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +41 -1
- package/dist/worktree-lifecycle.e2e.test.js +30 -30
- package/package.json +8 -8
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +56 -232
- package/skills/holocron/SKILL.md +117 -17
- package/src/agent-model.e2e.test.ts +293 -66
- package/src/anthropic-account-identity.ts +19 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +11 -16
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +6 -1
- package/src/cli-commands/session.ts +171 -105
- package/src/cli-commands/task.ts +22 -15
- package/src/cli-parsing.test.ts +34 -1
- package/src/cli-runner.ts +16 -3
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +199 -23
- package/src/commands/ask-question.ts +2 -1
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/merge-worktree.ts +13 -6
- package/src/commands/model-variant.ts +3 -2
- package/src/commands/model.ts +4 -3
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +40 -0
- package/src/db.test.ts +105 -0
- package/src/db.ts +12 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +43 -7
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +27 -11
- package/src/discord-utils.test.ts +157 -0
- package/src/discord-utils.ts +131 -1
- package/src/errors.ts +7 -0
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +16 -9
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/logger.ts +1 -0
- package/src/message-formatting.test.ts +586 -13
- package/src/message-formatting.ts +223 -46
- package/src/message-preprocessing.ts +116 -1
- package/src/opencode-command.test.ts +59 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +44 -4
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +5 -5
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-footer.e2e.test.ts +30 -31
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
- package/src/queue-advanced-question.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +20 -20
- package/src/queue-question-select-drain.e2e.test.ts +7 -7
- package/src/runtime-lifecycle.e2e.test.ts +16 -18
- package/src/schema.sql +1 -1
- package/src/schema.ts +1 -1
- package/src/session-handler/event-stream-state.test.ts +353 -0
- package/src/session-handler/event-stream-state.ts +179 -0
- package/src/session-handler/model-utils.test.ts +125 -3
- package/src/session-handler/model-utils.ts +36 -4
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +427 -158
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-title-rename.test.ts +45 -0
- package/src/sleep.e2e.test.ts +14 -11
- package/src/store.ts +1 -0
- package/src/system-message.test.ts +141 -10
- package/src/system-message.ts +66 -11
- package/src/task-runner.ts +2 -1
- package/src/task-schedule.test.ts +94 -0
- package/src/task-schedule.ts +47 -2
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -53
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +44 -0
- package/src/voice.ts +34 -6
- package/src/worktree-lifecycle.e2e.test.ts +30 -30
|
@@ -34,6 +34,21 @@ async function clearGlobalCommands({ rest, appId, }) {
|
|
|
34
34
|
function truncateCommandDescription(description) {
|
|
35
35
|
return description.slice(0, 100);
|
|
36
36
|
}
|
|
37
|
+
export function buildQuickAgentSlashCommand({ commandName, description, }) {
|
|
38
|
+
return new SlashCommandBuilder()
|
|
39
|
+
.setName(commandName)
|
|
40
|
+
.setDescription(truncateCommandDescription(description))
|
|
41
|
+
.setDMPermission(false)
|
|
42
|
+
.addStringOption((opt) => opt
|
|
43
|
+
.setName('prompt')
|
|
44
|
+
.setDescription('Send a prompt with this agent')
|
|
45
|
+
.setRequired(false))
|
|
46
|
+
.addStringOption((opt) => opt
|
|
47
|
+
.setName('variant')
|
|
48
|
+
.setDescription('Model thinking level for this agent')
|
|
49
|
+
.setRequired(false)
|
|
50
|
+
.setAutocomplete(true));
|
|
51
|
+
}
|
|
37
52
|
export async function registerCommands({ token, appId, guildIds, userCommands = [], agents = [], }) {
|
|
38
53
|
const commands = [
|
|
39
54
|
new SlashCommandBuilder()
|
|
@@ -373,15 +388,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
373
388
|
agentName: agent.name,
|
|
374
389
|
description: agent.description,
|
|
375
390
|
});
|
|
376
|
-
commands.push(
|
|
377
|
-
.setName(commandName)
|
|
378
|
-
.setDescription(truncateCommandDescription(description))
|
|
379
|
-
.setDMPermission(false)
|
|
380
|
-
.addStringOption((opt) => opt
|
|
381
|
-
.setName('prompt')
|
|
382
|
-
.setDescription('Send a prompt with this agent')
|
|
383
|
-
.setRequired(false))
|
|
384
|
-
.toJSON());
|
|
391
|
+
commands.push(buildQuickAgentSlashCommand({ commandName, description }).toJSON());
|
|
385
392
|
}
|
|
386
393
|
// 2. User-defined commands, MCP prompts, and skills (ordered by priority)
|
|
387
394
|
// Also populate registeredUserCommands in the store for /queue-command autocomplete
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Tests Discord slash command payloads for quick agent commands.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { buildQuickAgentSlashCommand } from './discord-command-registration.js';
|
|
4
|
+
describe('buildQuickAgentSlashCommand', () => {
|
|
5
|
+
test('puts variant last after prompt', () => {
|
|
6
|
+
const command = buildQuickAgentSlashCommand({
|
|
7
|
+
commandName: 'plan-agent',
|
|
8
|
+
description: 'Switch to plan agent',
|
|
9
|
+
}).toJSON();
|
|
10
|
+
expect(command.options?.map((option) => option.name)).toMatchInlineSnapshot(`
|
|
11
|
+
[
|
|
12
|
+
"prompt",
|
|
13
|
+
"variant",
|
|
14
|
+
]
|
|
15
|
+
`);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/dist/discord-utils.js
CHANGED
|
@@ -9,6 +9,7 @@ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Route
|
|
|
9
9
|
import { discordApiUrl } from './discord-urls.js';
|
|
10
10
|
import { Lexer } from 'marked';
|
|
11
11
|
import { splitTablesFromMarkdown } from './format-tables.js';
|
|
12
|
+
import { sessionPartContent, shouldLeadWithBlankLine, } from './message-formatting.js';
|
|
12
13
|
import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js';
|
|
13
14
|
import { DiscordOperationError } from './errors.js';
|
|
14
15
|
import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
@@ -222,6 +223,68 @@ export async function ensureThreadMember({ rest, threadId, userId, }) {
|
|
|
222
223
|
if (addMemberResult instanceof Error)
|
|
223
224
|
return addMemberResult;
|
|
224
225
|
}
|
|
226
|
+
export function resolveFooterMentionUserId({ sessionUserId, botUserId, threadOwnerId, members, }) {
|
|
227
|
+
if (sessionUserId && sessionUserId !== botUserId)
|
|
228
|
+
return sessionUserId;
|
|
229
|
+
if (!botUserId || threadOwnerId !== botUserId)
|
|
230
|
+
return undefined;
|
|
231
|
+
const humans = members
|
|
232
|
+
.filter((member) => member.id && member.id !== botUserId && member.bot === false)
|
|
233
|
+
.sort((a, b) => {
|
|
234
|
+
const aJoined = a.joinedTimestamp ?? Number.POSITIVE_INFINITY;
|
|
235
|
+
const bJoined = b.joinedTimestamp ?? Number.POSITIVE_INFINITY;
|
|
236
|
+
if (aJoined !== bJoined)
|
|
237
|
+
return aJoined - bJoined;
|
|
238
|
+
return a.id.localeCompare(b.id);
|
|
239
|
+
});
|
|
240
|
+
return humans[0]?.id;
|
|
241
|
+
}
|
|
242
|
+
function footerMentionMembersFromCache(thread) {
|
|
243
|
+
return [...thread.members.cache.values()].map((member) => ({
|
|
244
|
+
id: member.id,
|
|
245
|
+
bot: member.user?.bot,
|
|
246
|
+
joinedTimestamp: member.joinedTimestamp ?? null,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
export async function resolveThreadFooterMentionUserId({ sessionUserId, thread, }) {
|
|
250
|
+
const botUserId = thread.client.user?.id;
|
|
251
|
+
if (sessionUserId && sessionUserId !== botUserId)
|
|
252
|
+
return sessionUserId;
|
|
253
|
+
if (!botUserId || thread.ownerId !== botUserId)
|
|
254
|
+
return undefined;
|
|
255
|
+
const cached = footerMentionMembersFromCache(thread);
|
|
256
|
+
if (cached.some((member) => member.bot === false && member.id !== botUserId)) {
|
|
257
|
+
return resolveFooterMentionUserId({
|
|
258
|
+
sessionUserId,
|
|
259
|
+
botUserId,
|
|
260
|
+
threadOwnerId: thread.ownerId ?? undefined,
|
|
261
|
+
members: cached,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const fetched = await thread.members.fetch().catch((e) => {
|
|
265
|
+
return new DiscordOperationError({ operation: 'fetchThreadMembers', cause: e });
|
|
266
|
+
});
|
|
267
|
+
if (fetched instanceof Error) {
|
|
268
|
+
discordLogger.warn(`[FOOTER] Failed to fetch thread members: ${fetched.message}`);
|
|
269
|
+
return resolveFooterMentionUserId({
|
|
270
|
+
sessionUserId,
|
|
271
|
+
botUserId,
|
|
272
|
+
threadOwnerId: thread.ownerId ?? undefined,
|
|
273
|
+
members: cached,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const members = [...fetched.values()].map((member) => ({
|
|
277
|
+
id: member.id,
|
|
278
|
+
bot: member.user?.bot,
|
|
279
|
+
joinedTimestamp: member.joinedTimestamp ?? null,
|
|
280
|
+
}));
|
|
281
|
+
return resolveFooterMentionUserId({
|
|
282
|
+
sessionUserId,
|
|
283
|
+
botUserId,
|
|
284
|
+
threadOwnerId: thread.ownerId ?? undefined,
|
|
285
|
+
members,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
225
288
|
/** Remove Discord mentions from text so they don't appear in thread titles */
|
|
226
289
|
export function stripMentions(text) {
|
|
227
290
|
return text
|
|
@@ -517,6 +580,27 @@ export async function sendThreadMessage(thread, content, options) {
|
|
|
517
580
|
}
|
|
518
581
|
return firstMessage;
|
|
519
582
|
}
|
|
583
|
+
export async function sendSessionPartMessage(thread, content, options) {
|
|
584
|
+
return sendThreadMessage(thread, sessionPartContent({
|
|
585
|
+
content,
|
|
586
|
+
leadWithBlankLine: options?.leadWithBlankLine === true,
|
|
587
|
+
}), { flags: options?.flags });
|
|
588
|
+
}
|
|
589
|
+
export async function sendSessionPartBatches({ thread, batches, }) {
|
|
590
|
+
const sent = [];
|
|
591
|
+
let previousKind;
|
|
592
|
+
for (const batch of batches) {
|
|
593
|
+
const message = await sendSessionPartMessage(thread, batch.content, {
|
|
594
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
595
|
+
previousKind,
|
|
596
|
+
nextKind: batch.kind,
|
|
597
|
+
}),
|
|
598
|
+
});
|
|
599
|
+
previousKind = batch.kind;
|
|
600
|
+
sent.push({ partIds: batch.partIds, message });
|
|
601
|
+
}
|
|
602
|
+
return sent;
|
|
603
|
+
}
|
|
520
604
|
export function isThreadChannelType(type) {
|
|
521
605
|
return [
|
|
522
606
|
ChannelType.PublicThread,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PermissionsBitField } from 'discord.js';
|
|
2
2
|
import { afterEach, describe, expect, test } from 'vitest';
|
|
3
|
-
import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
3
|
+
import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveFooterMentionUserId, resolveGuildMessageMember, resolveThreadFooterMentionUserId, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
4
4
|
import { store } from './store.js';
|
|
5
5
|
describe('splitMarkdownForDiscord', () => {
|
|
6
6
|
test('never returns chunks over the max length with code fences', () => {
|
|
@@ -248,6 +248,140 @@ describe('resolveGuildMessageMember', () => {
|
|
|
248
248
|
await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
|
+
describe('resolveFooterMentionUserId', () => {
|
|
252
|
+
test('uses the session user when it is not the bot', () => {
|
|
253
|
+
expect(resolveFooterMentionUserId({
|
|
254
|
+
sessionUserId: 'user-1',
|
|
255
|
+
botUserId: 'bot-1',
|
|
256
|
+
threadOwnerId: 'bot-1',
|
|
257
|
+
members: [
|
|
258
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
259
|
+
{ id: 'user-2', bot: false, joinedTimestamp: 2 },
|
|
260
|
+
],
|
|
261
|
+
})).toBe('user-1');
|
|
262
|
+
});
|
|
263
|
+
test('skips bots and uses the earliest human member when the bot created the thread', () => {
|
|
264
|
+
expect(resolveFooterMentionUserId({
|
|
265
|
+
sessionUserId: 'bot-1',
|
|
266
|
+
botUserId: 'bot-1',
|
|
267
|
+
threadOwnerId: 'bot-1',
|
|
268
|
+
members: [
|
|
269
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
270
|
+
{ id: 'other-bot', bot: true, joinedTimestamp: 2 },
|
|
271
|
+
{ id: 'user-3', bot: false, joinedTimestamp: 4 },
|
|
272
|
+
{ id: 'user-2', bot: false, joinedTimestamp: 3 },
|
|
273
|
+
],
|
|
274
|
+
})).toBe('user-2');
|
|
275
|
+
});
|
|
276
|
+
test('does not mention anyone when the bot created the thread and no human member exists', () => {
|
|
277
|
+
expect(resolveFooterMentionUserId({
|
|
278
|
+
sessionUserId: 'bot-1',
|
|
279
|
+
botUserId: 'bot-1',
|
|
280
|
+
threadOwnerId: 'bot-1',
|
|
281
|
+
members: [
|
|
282
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
283
|
+
{ id: 'other-bot', bot: true, joinedTimestamp: 2 },
|
|
284
|
+
],
|
|
285
|
+
})).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
test('uses the first human member when the bot created the thread and no session user is set', () => {
|
|
288
|
+
expect(resolveFooterMentionUserId({
|
|
289
|
+
sessionUserId: undefined,
|
|
290
|
+
botUserId: 'bot-1',
|
|
291
|
+
threadOwnerId: 'bot-1',
|
|
292
|
+
members: [
|
|
293
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
294
|
+
{ id: 'user-2', bot: false, joinedTimestamp: 2 },
|
|
295
|
+
],
|
|
296
|
+
})).toBe('user-2');
|
|
297
|
+
});
|
|
298
|
+
test('does not mention a member whose bot flag is unknown', () => {
|
|
299
|
+
expect(resolveFooterMentionUserId({
|
|
300
|
+
sessionUserId: undefined,
|
|
301
|
+
botUserId: 'bot-1',
|
|
302
|
+
threadOwnerId: 'bot-1',
|
|
303
|
+
members: [
|
|
304
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
305
|
+
{ id: 'maybe-user', joinedTimestamp: 2 },
|
|
306
|
+
],
|
|
307
|
+
})).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
function fakeThread({ ownerId, botUserId, members, onFetch, }) {
|
|
311
|
+
const cache = new Map(members.map((member) => [
|
|
312
|
+
member.id,
|
|
313
|
+
{
|
|
314
|
+
id: member.id,
|
|
315
|
+
user: member.bot === undefined ? undefined : { bot: member.bot },
|
|
316
|
+
joinedTimestamp: member.joinedTimestamp ?? null,
|
|
317
|
+
},
|
|
318
|
+
]));
|
|
319
|
+
return {
|
|
320
|
+
ownerId,
|
|
321
|
+
client: { user: { id: botUserId } },
|
|
322
|
+
members: {
|
|
323
|
+
cache,
|
|
324
|
+
fetch: onFetch ?? (() => {
|
|
325
|
+
throw new Error('should not fetch thread members');
|
|
326
|
+
}),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
describe('resolveThreadFooterMentionUserId', () => {
|
|
331
|
+
test('does not fetch when a human session user is already known', async () => {
|
|
332
|
+
await expect(resolveThreadFooterMentionUserId({
|
|
333
|
+
sessionUserId: 'user-1',
|
|
334
|
+
thread: fakeThread({
|
|
335
|
+
ownerId: 'bot-1',
|
|
336
|
+
botUserId: 'bot-1',
|
|
337
|
+
members: [{ id: 'bot-1', bot: true }],
|
|
338
|
+
}),
|
|
339
|
+
})).resolves.toBe('user-1');
|
|
340
|
+
});
|
|
341
|
+
test('does not fetch when the thread owner is not the bot', async () => {
|
|
342
|
+
await expect(resolveThreadFooterMentionUserId({
|
|
343
|
+
sessionUserId: undefined,
|
|
344
|
+
thread: fakeThread({
|
|
345
|
+
ownerId: 'user-1',
|
|
346
|
+
botUserId: 'bot-1',
|
|
347
|
+
members: [{ id: 'user-1', bot: false }],
|
|
348
|
+
}),
|
|
349
|
+
})).resolves.toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
test('uses cached humans without fetching', async () => {
|
|
352
|
+
await expect(resolveThreadFooterMentionUserId({
|
|
353
|
+
sessionUserId: undefined,
|
|
354
|
+
thread: fakeThread({
|
|
355
|
+
ownerId: 'bot-1',
|
|
356
|
+
botUserId: 'bot-1',
|
|
357
|
+
members: [
|
|
358
|
+
{ id: 'bot-1', bot: true, joinedTimestamp: 1 },
|
|
359
|
+
{ id: 'user-2', bot: false, joinedTimestamp: 2 },
|
|
360
|
+
],
|
|
361
|
+
}),
|
|
362
|
+
})).resolves.toBe('user-2');
|
|
363
|
+
});
|
|
364
|
+
test('fetches only when the bot owns the thread and no human is cached', async () => {
|
|
365
|
+
let fetched = false;
|
|
366
|
+
const thread = fakeThread({
|
|
367
|
+
ownerId: 'bot-1',
|
|
368
|
+
botUserId: 'bot-1',
|
|
369
|
+
members: [{ id: 'bot-1', bot: true, joinedTimestamp: 1 }],
|
|
370
|
+
onFetch: async () => {
|
|
371
|
+
fetched = true;
|
|
372
|
+
return new Map([
|
|
373
|
+
['bot-1', { id: 'bot-1', user: { bot: true }, joinedTimestamp: 1 }],
|
|
374
|
+
['user-2', { id: 'user-2', user: { bot: false }, joinedTimestamp: 2 }],
|
|
375
|
+
]);
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
await expect(resolveThreadFooterMentionUserId({
|
|
379
|
+
sessionUserId: undefined,
|
|
380
|
+
thread,
|
|
381
|
+
})).resolves.toBe('user-2');
|
|
382
|
+
expect(fetched).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
251
385
|
describe('raceDiscordRename', () => {
|
|
252
386
|
test('returns timeout when rename hangs', async () => {
|
|
253
387
|
const result = await raceDiscordRename({
|
package/dist/errors.js
CHANGED
|
@@ -20,6 +20,11 @@ export class ServerNotReadyError extends errore.createTaggedError({
|
|
|
20
20
|
message: 'OpenCode client for directory "$directory" is not available because the shared server is not ready',
|
|
21
21
|
}) {
|
|
22
22
|
}
|
|
23
|
+
export class OpencodeIncompatibleVersionError extends errore.createTaggedError({
|
|
24
|
+
name: 'OpencodeIncompatibleVersionError',
|
|
25
|
+
message: 'Kimaki is not compatible with OpenCode version $version. Install an OpenCode 1.x release.',
|
|
26
|
+
}) {
|
|
27
|
+
}
|
|
23
28
|
export class ApiKeyMissingError extends errore.createTaggedError({
|
|
24
29
|
name: 'ApiKeyMissingError',
|
|
25
30
|
message: '$service API key is required',
|
|
@@ -64,18 +64,22 @@ describe('external directory permissions', () => {
|
|
|
64
64
|
"--- from: user (external-directory-tester)
|
|
65
65
|
EXTERNAL_DIRECTORY_PROBE_MARKER first
|
|
66
66
|
--- from: assistant (TestBot)
|
|
67
|
-
*using deterministic-provider/deterministic-v2*
|
|
68
|
-
|
|
67
|
+
> *using deterministic-provider/deterministic-v2*
|
|
68
|
+
> reading external directory
|
|
69
|
+
|
|
69
70
|
┣ read *probe.txt*
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
|
|
72
|
+
> external-directory-probe-done
|
|
73
|
+
> *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
|
|
72
74
|
--- from: user (external-directory-tester)
|
|
73
75
|
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
74
76
|
--- from: assistant (TestBot)
|
|
75
|
-
|
|
77
|
+
> reading external directory
|
|
78
|
+
|
|
76
79
|
┣ read *probe.txt*
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
|
|
81
|
+
> external-directory-probe-done
|
|
82
|
+
> *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
|
|
79
83
|
`);
|
|
80
84
|
expect(text).not.toContain('Permission Required');
|
|
81
85
|
});
|
|
@@ -54,10 +54,12 @@ describe('external directory project deny', () => {
|
|
|
54
54
|
"--- from: user (external-directory-deny-tester)
|
|
55
55
|
EXTERNAL_DIRECTORY_PROBE_MARKER denied
|
|
56
56
|
--- from: assistant (TestBot)
|
|
57
|
-
*using deterministic-provider/deterministic-v2*
|
|
58
|
-
|
|
57
|
+
> *using deterministic-provider/deterministic-v2*
|
|
58
|
+
> reading external directory
|
|
59
|
+
|
|
59
60
|
┣ read *probe.txt*
|
|
60
|
-
|
|
61
|
+
|
|
62
|
+
> external-directory-probe-denied"
|
|
61
63
|
`);
|
|
62
64
|
// A deny is silent: it must not fall back to asking the user.
|
|
63
65
|
expect(text).not.toContain('Permission Required');
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
3
3
|
import { getChannelVerbosity, getPartMessageIds, getThreadIdBySessionId, getThreadSessionSource, listTrackedTextChannels, setPartMessagesBatch, upsertThreadSession, } from './database.js';
|
|
4
|
-
import {
|
|
4
|
+
import { sendSessionPartBatches } from './discord-utils.js';
|
|
5
5
|
import { createLogger, LogPrefix } from './logger.js';
|
|
6
|
-
import { formatPart, collectSessionChunks, batchChunksForDiscord, } from './message-formatting.js';
|
|
6
|
+
import { formatPart, collectSessionChunks, batchChunksForDiscord, QUEUE_PREFIX, } from './message-formatting.js';
|
|
7
7
|
import { initializeOpencodeForDirectory, } from './opencode.js';
|
|
8
|
-
import { isEssentialToolPart } from './session-handler/thread-session-runtime.js';
|
|
8
|
+
import { getRuntime, isEssentialToolPart } from './session-handler/thread-session-runtime.js';
|
|
9
9
|
import { notifyError } from './sentry.js';
|
|
10
10
|
import { store } from './store.js';
|
|
11
11
|
import { extractNonXmlContent } from './xml.js';
|
|
@@ -20,8 +20,7 @@ const SYNC_PER_DIRECTORY_TIMEOUT_MS = 30_000;
|
|
|
20
20
|
const CLI_START_MS = Date.now() - 5 * 60 * 1000;
|
|
21
21
|
let externalSyncInterval = null;
|
|
22
22
|
function isSyntheticTextPart(part) {
|
|
23
|
-
|
|
24
|
-
return candidate.synthetic === true;
|
|
23
|
+
return part.synthetic === true;
|
|
25
24
|
}
|
|
26
25
|
function parseDiscordOriginMetadata(text) {
|
|
27
26
|
const match = text.match(/<discord-user\s+([^>]+)\s*\/>/);
|
|
@@ -64,7 +63,7 @@ function getDiscordOriginMetadataFromMessage({ message, }) {
|
|
|
64
63
|
}
|
|
65
64
|
return null;
|
|
66
65
|
}
|
|
67
|
-
function getRenderableUserTextParts({ message, }) {
|
|
66
|
+
export function getRenderableUserTextParts({ message, }) {
|
|
68
67
|
if (message.info.role !== 'user') {
|
|
69
68
|
return [];
|
|
70
69
|
}
|
|
@@ -72,7 +71,7 @@ function getRenderableUserTextParts({ message, }) {
|
|
|
72
71
|
if (part.type !== 'text') {
|
|
73
72
|
return [];
|
|
74
73
|
}
|
|
75
|
-
if (isSyntheticTextPart(part)) {
|
|
74
|
+
if (isSyntheticTextPart(part) || part.ignored === true) {
|
|
76
75
|
return [];
|
|
77
76
|
}
|
|
78
77
|
const cleanedText = extractNonXmlContent(part.text || '').trim();
|
|
@@ -82,8 +81,49 @@ function getRenderableUserTextParts({ message, }) {
|
|
|
82
81
|
return [{ id: part.id, text: cleanedText }];
|
|
83
82
|
});
|
|
84
83
|
}
|
|
84
|
+
function hasCompactionContinueMetadata(part) {
|
|
85
|
+
return part.metadata?.compaction_continue === true;
|
|
86
|
+
}
|
|
87
|
+
export function isInternalOpenCodeUserMessage({ message, }) {
|
|
88
|
+
if (message.info.role !== 'user') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return message.parts.some((part) => {
|
|
92
|
+
if (part.type === 'compaction') {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (part.type !== 'text') {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return isSyntheticTextPart(part) && hasCompactionContinueMetadata(part);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function shouldSkipExternalAssistantMessage({ message, }) {
|
|
102
|
+
if (message.info.role !== 'assistant') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return message.info.summary === true;
|
|
106
|
+
}
|
|
107
|
+
export function getIgnoredNoticeTextParts({ message, }) {
|
|
108
|
+
if (message.info.role !== 'user') {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return message.parts.flatMap((part) => {
|
|
112
|
+
if (part.type !== 'text') {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
if (part.ignored !== true || isSyntheticTextPart(part)) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const text = part.text?.trim();
|
|
119
|
+
if (!text) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return [{ id: part.id, text }];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
85
125
|
function getExternalUserMirrorText({ username, prompt, }) {
|
|
86
|
-
return
|
|
126
|
+
return `${QUEUE_PREFIX}**${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
87
127
|
}
|
|
88
128
|
// Pure derivation: is the latest user turn from Discord?
|
|
89
129
|
// Checks the newest user message with renderable text for a <discord-user />
|
|
@@ -91,12 +131,15 @@ function getExternalUserMirrorText({ username, prompt, }) {
|
|
|
91
131
|
// (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
|
|
92
132
|
// external sync should mirror it — this naturally handles the "reclaim" case
|
|
93
133
|
// (external → discord → external) without any DB source toggling.
|
|
94
|
-
function isLatestUserTurnFromDiscord({ messages, }) {
|
|
134
|
+
export function isLatestUserTurnFromDiscord({ messages, }) {
|
|
95
135
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
96
136
|
const message = messages[i];
|
|
97
137
|
if (message.info.role !== 'user') {
|
|
98
138
|
continue;
|
|
99
139
|
}
|
|
140
|
+
if (isInternalOpenCodeUserMessage({ message })) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
100
143
|
const renderableParts = getRenderableUserTextParts({ message });
|
|
101
144
|
if (renderableParts.length === 0) {
|
|
102
145
|
continue;
|
|
@@ -234,6 +277,23 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
|
|
|
234
277
|
const directMappings = [];
|
|
235
278
|
for (const message of messages) {
|
|
236
279
|
if (message.info.role === 'user') {
|
|
280
|
+
if (isInternalOpenCodeUserMessage({ message })) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const ignoredNoticeParts = getIgnoredNoticeTextParts({ message }).filter((part) => {
|
|
284
|
+
return !syncedPartIds.has(part.id);
|
|
285
|
+
});
|
|
286
|
+
if (ignoredNoticeParts.length > 0) {
|
|
287
|
+
chunks.push({
|
|
288
|
+
partIds: ignoredNoticeParts.map((part) => {
|
|
289
|
+
return part.id;
|
|
290
|
+
}),
|
|
291
|
+
content: ignoredNoticeParts.map((part) => {
|
|
292
|
+
return part.text;
|
|
293
|
+
}).join('\n\n'),
|
|
294
|
+
kind: 'text',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
237
297
|
const renderableParts = getRenderableUserTextParts({ message });
|
|
238
298
|
const unsyncedParts = renderableParts.filter((p) => {
|
|
239
299
|
return !syncedPartIds.has(p.id);
|
|
@@ -265,12 +325,16 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
|
|
|
265
325
|
return p.id;
|
|
266
326
|
}),
|
|
267
327
|
content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
|
|
328
|
+
kind: 'text',
|
|
268
329
|
});
|
|
269
330
|
continue;
|
|
270
331
|
}
|
|
271
332
|
if (message.info.role !== 'assistant') {
|
|
272
333
|
continue;
|
|
273
334
|
}
|
|
335
|
+
if (shouldSkipExternalAssistantMessage({ message })) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
274
338
|
// Filter assistant parts by verbosity before passing to shared collector
|
|
275
339
|
const filteredParts = message.parts.filter((part) => {
|
|
276
340
|
return shouldMirrorAssistantPart({ part, verbosity });
|
|
@@ -314,6 +378,10 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
|
|
|
314
378
|
if (isLatestUserTurnFromDiscord({ messages })) {
|
|
315
379
|
return;
|
|
316
380
|
}
|
|
381
|
+
const existingThreadId = await getThreadIdBySessionId(sessionId);
|
|
382
|
+
if (existingThreadId && getRuntime(existingThreadId)) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
317
385
|
const thread = await ensureExternalSessionThread({
|
|
318
386
|
discordClient,
|
|
319
387
|
channelId,
|
|
@@ -340,13 +408,15 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
|
|
|
340
408
|
await setPartMessagesBatch(directMappings);
|
|
341
409
|
}
|
|
342
410
|
const batched = batchChunksForDiscord(chunks);
|
|
343
|
-
|
|
411
|
+
if (signal.aborted)
|
|
412
|
+
return;
|
|
413
|
+
const sent = await sendSessionPartBatches({ thread, batches: batched });
|
|
414
|
+
for (const batch of sent) {
|
|
344
415
|
if (signal.aborted)
|
|
345
416
|
return;
|
|
346
|
-
const sentMessage = await sendThreadMessage(thread, batch.content);
|
|
347
417
|
await setPartMessagesBatch(batch.partIds.map((partId) => ({
|
|
348
418
|
partId,
|
|
349
|
-
messageId:
|
|
419
|
+
messageId: batch.message.id,
|
|
350
420
|
threadId: thread.id,
|
|
351
421
|
})));
|
|
352
422
|
}
|
|
@@ -377,6 +447,15 @@ async function pulseTypingForBusySessions({ discordClient, statuses, }) {
|
|
|
377
447
|
}
|
|
378
448
|
}
|
|
379
449
|
const EXTERNAL_SYNC_MAX_SESSIONS = 50;
|
|
450
|
+
export function isExternalSyncRootSession({ title, parentID, }) {
|
|
451
|
+
if (parentID) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
if (/^new session\s*-/i.test(title || '')) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return !/subagent\)\s*$/i.test(title || '');
|
|
458
|
+
}
|
|
380
459
|
// Tracks directories with an in-flight sync. When a directory times out,
|
|
381
460
|
// its AbortController is aborted so the inner work stops producing side
|
|
382
461
|
// effects (Discord messages, DB writes). The next poll tick skips the
|
|
@@ -449,11 +528,7 @@ async function syncDirectoryInner({ directory, channelId, startMs, discordClient
|
|
|
449
528
|
if (signal.aborted)
|
|
450
529
|
return;
|
|
451
530
|
const sessions = (sessionsResponse.data || []).filter((session) => {
|
|
452
|
-
|
|
453
|
-
if (/^new session\s*-/i.test(title)) {
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
|
-
return !/subagent\)\s*$/i.test(title);
|
|
531
|
+
return isExternalSyncRootSession(session);
|
|
457
532
|
});
|
|
458
533
|
const sorted = sortSessionsByRecency(sessions);
|
|
459
534
|
for (const session of sorted) {
|
|
@@ -540,4 +615,6 @@ export const externalOpencodeSyncInternals = {
|
|
|
540
615
|
parseDiscordOriginMetadata,
|
|
541
616
|
getDiscordOriginMetadataFromMessage,
|
|
542
617
|
isLatestUserTurnFromDiscord,
|
|
618
|
+
isInternalOpenCodeUserMessage,
|
|
619
|
+
shouldSkipExternalAssistantMessage,
|
|
543
620
|
};
|