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
package/dist/commands/model.js
CHANGED
|
@@ -321,15 +321,16 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
321
321
|
});
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
324
|
-
const currentModelText = (() => {
|
|
324
|
+
const currentModelText = await (async () => {
|
|
325
325
|
if (currentModelInfo.type === 'none') {
|
|
326
326
|
return '**Current:** none';
|
|
327
327
|
}
|
|
328
|
-
const displayed = resolveDisplayedModelId({
|
|
328
|
+
const displayed = (await resolveDisplayedModelId({
|
|
329
329
|
providers: allProviders,
|
|
330
330
|
providerID: currentModelInfo.providerID,
|
|
331
331
|
modelID: currentModelInfo.modelID,
|
|
332
|
-
|
|
332
|
+
sessionID: sessionId,
|
|
333
|
+
})) ?? currentModelInfo.model;
|
|
333
334
|
switch (currentModelInfo.type) {
|
|
334
335
|
case 'session':
|
|
335
336
|
return `**Current (this thread):** \`${displayed}\``;
|
package/dist/commands/queue.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils
|
|
|
6
6
|
import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
7
7
|
import { buildHtmlActionCustomId, registerHtmlAction, } from '../html-actions.js';
|
|
8
8
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
|
+
import { QUEUE_PREFIX } from '../message-formatting.js';
|
|
9
10
|
import { store } from '../store.js';
|
|
10
11
|
const logger = createLogger(LogPrefix.QUEUE);
|
|
11
12
|
function buildQueueRemoveRow({ threadId, queueId, position, }) {
|
|
@@ -108,7 +109,7 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
108
109
|
});
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
|
-
const responseText =
|
|
112
|
+
const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`;
|
|
112
113
|
await command.reply({
|
|
113
114
|
content: responseText,
|
|
114
115
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -264,7 +265,7 @@ export async function handleQueueCommandCommand({ command, appId, }) {
|
|
|
264
265
|
logger.log(`[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`);
|
|
265
266
|
return;
|
|
266
267
|
}
|
|
267
|
-
const responseText =
|
|
268
|
+
const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${displayText}`;
|
|
268
269
|
await command.reply({
|
|
269
270
|
content: responseText,
|
|
270
271
|
flags: SILENT_MESSAGE_FLAGS,
|
package/dist/commands/resume.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { getChannelDirectory, setThreadSession, setPartMessagesBatch, getAllThreadSessionIds, } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
|
-
import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
|
+
import { sendThreadMessage, sendSessionPartBatches, resolveProjectDirectoryFromAutocomplete, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
7
7
|
import { collectSessionChunks, batchChunksForDiscord } from '../message-formatting.js';
|
|
8
8
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
9
|
import * as errore from 'errore';
|
|
@@ -79,11 +79,11 @@ export async function handleResumeCommand({ command, }) {
|
|
|
79
79
|
await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
|
|
80
80
|
}
|
|
81
81
|
const batched = batchChunksForDiscord(chunks);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
const sent = await sendSessionPartBatches({ thread, batches: batched });
|
|
83
|
+
for (const batch of sent) {
|
|
84
84
|
await setPartMessagesBatch(batch.partIds.map((partId) => ({
|
|
85
85
|
partId,
|
|
86
|
-
messageId:
|
|
86
|
+
messageId: batch.message.id,
|
|
87
87
|
threadId: thread.id,
|
|
88
88
|
})));
|
|
89
89
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -2,16 +2,56 @@
|
|
|
2
2
|
// Works in both text channels and threads. When used in a thread, the new
|
|
3
3
|
// session inherits the same working directory (worktree/workspace) so the
|
|
4
4
|
// user stays in the same folder context.
|
|
5
|
+
// Also owns the fresh-thread creation flow used by voice routing.
|
|
5
6
|
import { ChannelType } from 'discord.js';
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { getChannelDirectory, getThreadWorktreeOrWorkspace, createPendingWorkspace, setWorkspaceReady, } from '../database.js';
|
|
9
10
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
10
|
-
import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete, resolveWorkingDirectory, resolveTextChannel, } from '../discord-utils.js';
|
|
11
|
+
import { sendThreadMessage, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete, resolveWorkingDirectory, resolveTextChannel, } from '../discord-utils.js';
|
|
11
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
12
13
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
|
-
import
|
|
14
|
+
import { DiscordOperationError } from '../errors.js';
|
|
14
15
|
const logger = createLogger(LogPrefix.SESSION);
|
|
16
|
+
// Create only: callers confirm the destination before dispatching the full prompt.
|
|
17
|
+
export async function createNewSessionThread({ textChannel, projectDirectory, sourceWorkspace, prompt, files = [], userId, }) {
|
|
18
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
19
|
+
if (getClient instanceof Error)
|
|
20
|
+
return getClient;
|
|
21
|
+
const [starter, ...remaining] = splitMarkdownForDiscord({
|
|
22
|
+
content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
|
|
23
|
+
maxLength: 2000,
|
|
24
|
+
});
|
|
25
|
+
if (!starter)
|
|
26
|
+
return new Error('No session request to send. Retry with /new-session and a prompt.');
|
|
27
|
+
const starterMessage = await textChannel.send({ content: starter, flags: SILENT_MESSAGE_FLAGS });
|
|
28
|
+
const thread = await starterMessage.startThread({
|
|
29
|
+
name: prompt.slice(0, 100),
|
|
30
|
+
autoArchiveDuration: 1440,
|
|
31
|
+
reason: 'OpenCode session',
|
|
32
|
+
});
|
|
33
|
+
for (const chunk of remaining) {
|
|
34
|
+
await sendThreadMessage(thread, chunk);
|
|
35
|
+
}
|
|
36
|
+
// Persist the directory association for later commands and bot restarts.
|
|
37
|
+
if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
|
|
38
|
+
await createPendingWorkspace({
|
|
39
|
+
threadId: thread.id,
|
|
40
|
+
workspaceType: sourceWorkspace.workspace_type,
|
|
41
|
+
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
42
|
+
projectDirectory,
|
|
43
|
+
});
|
|
44
|
+
await setWorkspaceReady({
|
|
45
|
+
threadId: thread.id,
|
|
46
|
+
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
47
|
+
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
await thread.members.add(userId).catch((error) => {
|
|
51
|
+
logger.warn('Could not add session member:', error);
|
|
52
|
+
});
|
|
53
|
+
return thread;
|
|
54
|
+
}
|
|
15
55
|
export async function handleSessionCommand({ command, appId, }) {
|
|
16
56
|
await command.deferReply();
|
|
17
57
|
const prompt = command.options.getString('prompt', true);
|
|
@@ -67,68 +107,43 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
67
107
|
await command.editReply(`Directory does not exist: ${projectDirectory}`);
|
|
68
108
|
return;
|
|
69
109
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
87
|
-
});
|
|
88
|
-
const thread = await starterMessage.startThread({
|
|
89
|
-
name: prompt.slice(0, 100),
|
|
90
|
-
autoArchiveDuration: 1440,
|
|
91
|
-
reason: 'OpenCode session',
|
|
92
|
-
});
|
|
93
|
-
// Persist workspace association so commands in the new thread resolve the
|
|
94
|
-
// correct working directory, and the runtime survives bot restarts.
|
|
95
|
-
if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
|
|
96
|
-
await createPendingWorkspace({
|
|
97
|
-
threadId: thread.id,
|
|
98
|
-
workspaceType: sourceWorkspace.workspace_type,
|
|
99
|
-
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
100
|
-
projectDirectory,
|
|
101
|
-
});
|
|
102
|
-
await setWorkspaceReady({
|
|
103
|
-
threadId: thread.id,
|
|
104
|
-
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
105
|
-
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
// Add user to thread so it appears in their sidebar
|
|
109
|
-
await thread.members.add(command.user.id);
|
|
110
|
-
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
111
|
-
const runtime = getOrCreateRuntime({
|
|
112
|
-
threadId: thread.id,
|
|
113
|
-
thread,
|
|
114
|
-
projectDirectory,
|
|
115
|
-
sdkDirectory,
|
|
116
|
-
channelId: textChannel.id,
|
|
117
|
-
appId,
|
|
118
|
-
});
|
|
119
|
-
await runtime.enqueueIncoming({
|
|
120
|
-
prompt: fullPrompt,
|
|
121
|
-
userId: command.user.id,
|
|
122
|
-
username: command.user.displayName,
|
|
123
|
-
agent,
|
|
124
|
-
appId,
|
|
125
|
-
mode: 'opencode',
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
logger.error('[SESSION] Error:', error);
|
|
130
|
-
await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
110
|
+
const files = filesString
|
|
111
|
+
.split(',')
|
|
112
|
+
.map((f) => f.trim())
|
|
113
|
+
.filter((f) => f);
|
|
114
|
+
const thread = await createNewSessionThread({
|
|
115
|
+
textChannel,
|
|
116
|
+
projectDirectory,
|
|
117
|
+
sourceWorkspace,
|
|
118
|
+
prompt,
|
|
119
|
+
files,
|
|
120
|
+
userId: command.user.id,
|
|
121
|
+
}).catch((cause) => new DiscordOperationError({ operation: 'createSessionThread', cause }));
|
|
122
|
+
if (thread instanceof Error) {
|
|
123
|
+
logger.error('New session creation failed:', thread);
|
|
124
|
+
await command.editReply(`${thread.message}. Retry with /new-session.`);
|
|
125
|
+
return;
|
|
131
126
|
}
|
|
127
|
+
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
128
|
+
const runtime = getOrCreateRuntime({
|
|
129
|
+
threadId: thread.id,
|
|
130
|
+
thread,
|
|
131
|
+
projectDirectory,
|
|
132
|
+
sdkDirectory,
|
|
133
|
+
channelId: textChannel.id,
|
|
134
|
+
appId,
|
|
135
|
+
});
|
|
136
|
+
await runtime.enqueueIncoming({
|
|
137
|
+
prompt: files.length > 0 ? `${prompt}\n\n@${files.join(' @')}` : prompt,
|
|
138
|
+
userId: command.user.id,
|
|
139
|
+
username: command.user.displayName,
|
|
140
|
+
agent,
|
|
141
|
+
appId,
|
|
142
|
+
mode: 'opencode',
|
|
143
|
+
}).catch(async (error) => {
|
|
144
|
+
logger.error('New session dispatch failed:', error);
|
|
145
|
+
await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
|
|
146
|
+
});
|
|
132
147
|
}
|
|
133
148
|
async function handleAgentAutocomplete({ interaction, }) {
|
|
134
149
|
const focusedValue = interaction.options.getFocused();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Live CPU profiling for the running Kimaki process.
|
|
2
|
+
// Type `cpuprof` in the bot terminal to start. Type it again to stop,
|
|
3
|
+
// or wait 20s for auto-stop. Writes Chrome DevTools .cpuprofile files
|
|
4
|
+
// to <dataDir>/cpu-profiles/.
|
|
5
|
+
import * as errore from 'errore';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import inspector from 'node:inspector/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import readline from 'node:readline';
|
|
10
|
+
import { getDataDir } from './config.js';
|
|
11
|
+
import { FilesystemOperationError } from './errors.js';
|
|
12
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
13
|
+
const logger = createLogger(LogPrefix.CPU);
|
|
14
|
+
export const CPU_PROF_AUTO_STOP_MS = 20_000;
|
|
15
|
+
class CpuProfilerError extends errore.createTaggedError({
|
|
16
|
+
name: 'CpuProfilerError',
|
|
17
|
+
message: '$reason',
|
|
18
|
+
}) {
|
|
19
|
+
}
|
|
20
|
+
let session = null;
|
|
21
|
+
let autoStopTimer = null;
|
|
22
|
+
let stdinReader = null;
|
|
23
|
+
let lock = Promise.resolve();
|
|
24
|
+
function withLock(fn) {
|
|
25
|
+
const run = lock.then(fn, fn);
|
|
26
|
+
lock = run.then(() => { }, () => { });
|
|
27
|
+
return run;
|
|
28
|
+
}
|
|
29
|
+
function getCpuProfileDir() {
|
|
30
|
+
return path.join(getDataDir(), 'cpu-profiles');
|
|
31
|
+
}
|
|
32
|
+
function cpuProfilePath() {
|
|
33
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
34
|
+
return path.join(getCpuProfileDir(), `cpu-${timestamp}.cpuprofile`);
|
|
35
|
+
}
|
|
36
|
+
export function isCpuProfiling() {
|
|
37
|
+
return session !== null;
|
|
38
|
+
}
|
|
39
|
+
export function parseStdinCommand(line) {
|
|
40
|
+
if (line.trim().toLowerCase() === 'cpuprof')
|
|
41
|
+
return 'cpuprof';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function clearAutoStopTimer() {
|
|
45
|
+
if (!autoStopTimer)
|
|
46
|
+
return;
|
|
47
|
+
clearTimeout(autoStopTimer);
|
|
48
|
+
autoStopTimer = null;
|
|
49
|
+
}
|
|
50
|
+
function scheduleAutoStop(autoStopMs) {
|
|
51
|
+
clearAutoStopTimer();
|
|
52
|
+
autoStopTimer = setTimeout(() => {
|
|
53
|
+
autoStopTimer = null;
|
|
54
|
+
void stopCpuProfiling().then((result) => {
|
|
55
|
+
if (result instanceof Error) {
|
|
56
|
+
logger.warn('CPU profile auto-stop failed:', result.message);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
logger.log(`CPU profile auto-stopped: ${result.path}`);
|
|
60
|
+
});
|
|
61
|
+
}, autoStopMs);
|
|
62
|
+
autoStopTimer.unref();
|
|
63
|
+
}
|
|
64
|
+
async function startUnlocked({ autoStopMs, }) {
|
|
65
|
+
if (session) {
|
|
66
|
+
return new CpuProfilerError({
|
|
67
|
+
reason: 'CPU profiling is already running. Type cpuprof again to stop.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const next = new inspector.Session();
|
|
71
|
+
const connected = errore.try(() => {
|
|
72
|
+
next.connect();
|
|
73
|
+
}, (e) => new CpuProfilerError({
|
|
74
|
+
reason: 'Failed to connect inspector. Type cpuprof again after a few seconds.',
|
|
75
|
+
cause: e,
|
|
76
|
+
}));
|
|
77
|
+
if (connected instanceof Error)
|
|
78
|
+
return connected;
|
|
79
|
+
const enabled = await next.post('Profiler.enable').catch((e) => new CpuProfilerError({
|
|
80
|
+
reason: 'Failed to enable profiler',
|
|
81
|
+
cause: e,
|
|
82
|
+
}));
|
|
83
|
+
if (enabled instanceof Error) {
|
|
84
|
+
next.disconnect();
|
|
85
|
+
return enabled;
|
|
86
|
+
}
|
|
87
|
+
const started = await next.post('Profiler.start').catch((e) => new CpuProfilerError({
|
|
88
|
+
reason: 'Failed to start profiler',
|
|
89
|
+
cause: e,
|
|
90
|
+
}));
|
|
91
|
+
if (started instanceof Error) {
|
|
92
|
+
await next.post('Profiler.disable').catch((e) => {
|
|
93
|
+
logger.warn('Failed to disable profiler after start error:', e);
|
|
94
|
+
});
|
|
95
|
+
next.disconnect();
|
|
96
|
+
return started;
|
|
97
|
+
}
|
|
98
|
+
session = next;
|
|
99
|
+
scheduleAutoStop(autoStopMs);
|
|
100
|
+
logger.log(`CPU profiling started. Type cpuprof again to stop, or wait ${autoStopMs / 1000}s.`);
|
|
101
|
+
return { action: 'started', autoStopMs };
|
|
102
|
+
}
|
|
103
|
+
async function stopUnlocked() {
|
|
104
|
+
if (!session) {
|
|
105
|
+
return new CpuProfilerError({
|
|
106
|
+
reason: 'CPU profiling is not running. Type cpuprof to start.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const current = session;
|
|
110
|
+
session = null;
|
|
111
|
+
clearAutoStopTimer();
|
|
112
|
+
const stopped = await current.post('Profiler.stop').catch((e) => new CpuProfilerError({
|
|
113
|
+
reason: 'Failed to stop profiler',
|
|
114
|
+
cause: e,
|
|
115
|
+
}));
|
|
116
|
+
await current.post('Profiler.disable').catch((e) => {
|
|
117
|
+
logger.warn('Failed to disable profiler:', e);
|
|
118
|
+
});
|
|
119
|
+
current.disconnect();
|
|
120
|
+
if (stopped instanceof Error)
|
|
121
|
+
return stopped;
|
|
122
|
+
const profile = stopped.profile;
|
|
123
|
+
if (!profile) {
|
|
124
|
+
return new CpuProfilerError({
|
|
125
|
+
reason: 'Profiler stop returned no profile. Try cpuprof again.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const dir = getCpuProfileDir();
|
|
129
|
+
const created = errore.try(() => {
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
131
|
+
}, (e) => new FilesystemOperationError({
|
|
132
|
+
operation: `mkdir ${dir}`,
|
|
133
|
+
cause: e,
|
|
134
|
+
}));
|
|
135
|
+
if (created instanceof Error)
|
|
136
|
+
return created;
|
|
137
|
+
const filepath = cpuProfilePath();
|
|
138
|
+
const written = await fs.promises
|
|
139
|
+
.writeFile(filepath, JSON.stringify(profile))
|
|
140
|
+
.catch((e) => new FilesystemOperationError({
|
|
141
|
+
operation: `write ${filepath}`,
|
|
142
|
+
cause: e,
|
|
143
|
+
}));
|
|
144
|
+
if (written instanceof Error)
|
|
145
|
+
return written;
|
|
146
|
+
logger.log(`CPU profile written: ${filepath}`);
|
|
147
|
+
return { action: 'stopped', path: filepath };
|
|
148
|
+
}
|
|
149
|
+
export function startCpuProfiling({ autoStopMs = CPU_PROF_AUTO_STOP_MS, } = {}) {
|
|
150
|
+
return withLock(() => startUnlocked({ autoStopMs }));
|
|
151
|
+
}
|
|
152
|
+
export function stopCpuProfiling() {
|
|
153
|
+
return withLock(() => stopUnlocked());
|
|
154
|
+
}
|
|
155
|
+
export function toggleCpuProfiling({ autoStopMs = CPU_PROF_AUTO_STOP_MS, } = {}) {
|
|
156
|
+
return withLock(async () => {
|
|
157
|
+
if (session)
|
|
158
|
+
return stopUnlocked();
|
|
159
|
+
return startUnlocked({ autoStopMs });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export function flushCpuProfiling() {
|
|
163
|
+
return withLock(async () => {
|
|
164
|
+
if (!session)
|
|
165
|
+
return null;
|
|
166
|
+
return stopUnlocked();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export async function handleStdinLine(line, { autoStopMs = CPU_PROF_AUTO_STOP_MS, } = {}) {
|
|
170
|
+
if (parseStdinCommand(line) !== 'cpuprof')
|
|
171
|
+
return null;
|
|
172
|
+
return toggleCpuProfiling({ autoStopMs });
|
|
173
|
+
}
|
|
174
|
+
export function startStdinCpuProfListener({ stdin, autoStopMs = CPU_PROF_AUTO_STOP_MS, } = {}) {
|
|
175
|
+
if (stdinReader)
|
|
176
|
+
return;
|
|
177
|
+
const input = stdin ?? process.stdin;
|
|
178
|
+
if (!stdin) {
|
|
179
|
+
if (process.env.KIMAKI_VITEST)
|
|
180
|
+
return;
|
|
181
|
+
if (!process.stdin.isTTY)
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
stdinReader = readline.createInterface({ input, terminal: false });
|
|
185
|
+
stdinReader.on('line', (line) => {
|
|
186
|
+
void handleStdinLine(line, { autoStopMs }).then((result) => {
|
|
187
|
+
if (result instanceof Error)
|
|
188
|
+
logger.warn(result.message);
|
|
189
|
+
}, (error) => {
|
|
190
|
+
logger.warn('CPU profile command failed:', error instanceof Error ? error.message : String(error));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
logger.log('Type cpuprof then Enter to capture a CPU profile');
|
|
194
|
+
}
|
|
195
|
+
export function stopStdinCpuProfListener() {
|
|
196
|
+
if (!stdinReader)
|
|
197
|
+
return;
|
|
198
|
+
stdinReader.close();
|
|
199
|
+
stdinReader = null;
|
|
200
|
+
}
|
|
201
|
+
export async function _resetCpuProfilerForTests() {
|
|
202
|
+
stopStdinCpuProfListener();
|
|
203
|
+
clearAutoStopTimer();
|
|
204
|
+
await lock;
|
|
205
|
+
if (!session)
|
|
206
|
+
return;
|
|
207
|
+
const stopped = await withLock(() => stopUnlocked());
|
|
208
|
+
if (stopped instanceof Error) {
|
|
209
|
+
logger.warn('Failed to reset CPU profiler:', stopped.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Tests for live CPU profiling via stdin `cpuprof` and node:inspector.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
+
import { setDataDir } from './config.js';
|
|
8
|
+
import { CPU_PROF_AUTO_STOP_MS, handleStdinLine, isCpuProfiling, parseStdinCommand, startCpuProfiling, startStdinCpuProfListener, stopCpuProfiling, stopStdinCpuProfListener, toggleCpuProfiling, flushCpuProfiling, _resetCpuProfilerForTests, } from './cpu-profiler.js';
|
|
9
|
+
const tempDirs = [];
|
|
10
|
+
function makeDataDir() {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-cpu-profiler-'));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
setDataDir(dir);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
function cpuProfileDir(dataDir) {
|
|
17
|
+
return path.join(dataDir, 'cpu-profiles');
|
|
18
|
+
}
|
|
19
|
+
function listProfiles(dataDir) {
|
|
20
|
+
const dir = cpuProfileDir(dataDir);
|
|
21
|
+
if (!fs.existsSync(dir))
|
|
22
|
+
return [];
|
|
23
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith('.cpuprofile'));
|
|
24
|
+
}
|
|
25
|
+
function burnCpu(ms) {
|
|
26
|
+
const end = Date.now() + ms;
|
|
27
|
+
while (Date.now() < end)
|
|
28
|
+
Math.sqrt(Math.random());
|
|
29
|
+
}
|
|
30
|
+
async function waitUntil(predicate, timeoutMs = 2000) {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
if (predicate())
|
|
34
|
+
return;
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
36
|
+
}
|
|
37
|
+
throw new Error('timed out waiting for condition');
|
|
38
|
+
}
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
makeDataDir();
|
|
41
|
+
});
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await _resetCpuProfilerForTests();
|
|
44
|
+
for (const dir of tempDirs.splice(0)) {
|
|
45
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
describe('parseStdinCommand', () => {
|
|
49
|
+
test('matches cpuprof after trim, ignoring case', () => {
|
|
50
|
+
expect(parseStdinCommand('cpuprof')).toBe('cpuprof');
|
|
51
|
+
expect(parseStdinCommand(' CPUProf \n')).toBe('cpuprof');
|
|
52
|
+
expect(parseStdinCommand('cpuprof now')).toBeNull();
|
|
53
|
+
expect(parseStdinCommand('heap')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe.sequential('cpu profiler', () => {
|
|
57
|
+
test('auto-stop default is 20 seconds', () => {
|
|
58
|
+
expect(CPU_PROF_AUTO_STOP_MS).toBe(20_000);
|
|
59
|
+
});
|
|
60
|
+
test('start then stop writes a chrome cpuprofile under the data dir', async () => {
|
|
61
|
+
const dataDir = makeDataDir();
|
|
62
|
+
const started = await startCpuProfiling({ autoStopMs: 10_000 });
|
|
63
|
+
expect(started).not.toBeInstanceOf(Error);
|
|
64
|
+
expect(isCpuProfiling()).toBe(true);
|
|
65
|
+
burnCpu(50);
|
|
66
|
+
const stopped = await stopCpuProfiling();
|
|
67
|
+
expect(stopped).not.toBeInstanceOf(Error);
|
|
68
|
+
if (stopped instanceof Error)
|
|
69
|
+
return;
|
|
70
|
+
expect(isCpuProfiling()).toBe(false);
|
|
71
|
+
expect(stopped.path.startsWith(cpuProfileDir(dataDir))).toBe(true);
|
|
72
|
+
expect(stopped.path).toMatch(/cpu-.*\.cpuprofile$/);
|
|
73
|
+
expect(fs.existsSync(stopped.path)).toBe(true);
|
|
74
|
+
const profile = JSON.parse(fs.readFileSync(stopped.path, 'utf8'));
|
|
75
|
+
expect(Array.isArray(profile.nodes)).toBe(true);
|
|
76
|
+
expect(listProfiles(dataDir)).toEqual([path.basename(stopped.path)]);
|
|
77
|
+
});
|
|
78
|
+
test('start while already running returns an error', async () => {
|
|
79
|
+
const started = await startCpuProfiling({ autoStopMs: 10_000 });
|
|
80
|
+
expect(started).not.toBeInstanceOf(Error);
|
|
81
|
+
const again = await startCpuProfiling({ autoStopMs: 10_000 });
|
|
82
|
+
expect(again).toBeInstanceOf(Error);
|
|
83
|
+
expect(isCpuProfiling()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
test('stop while idle returns an error', async () => {
|
|
86
|
+
const stopped = await stopCpuProfiling();
|
|
87
|
+
expect(stopped).toBeInstanceOf(Error);
|
|
88
|
+
});
|
|
89
|
+
test('toggle starts then stops', async () => {
|
|
90
|
+
const dataDir = makeDataDir();
|
|
91
|
+
const started = await toggleCpuProfiling({ autoStopMs: 10_000 });
|
|
92
|
+
expect(started).not.toBeInstanceOf(Error);
|
|
93
|
+
if (started instanceof Error)
|
|
94
|
+
return;
|
|
95
|
+
expect(started.action).toBe('started');
|
|
96
|
+
expect(isCpuProfiling()).toBe(true);
|
|
97
|
+
burnCpu(30);
|
|
98
|
+
const stopped = await toggleCpuProfiling({ autoStopMs: 10_000 });
|
|
99
|
+
expect(stopped).not.toBeInstanceOf(Error);
|
|
100
|
+
if (stopped instanceof Error)
|
|
101
|
+
return;
|
|
102
|
+
expect(stopped.action).toBe('stopped');
|
|
103
|
+
if (stopped.action !== 'stopped')
|
|
104
|
+
return;
|
|
105
|
+
expect(fs.existsSync(stopped.path)).toBe(true);
|
|
106
|
+
expect(listProfiles(dataDir)).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
test('auto-stops after the timeout and writes a profile', async () => {
|
|
109
|
+
const dataDir = makeDataDir();
|
|
110
|
+
const started = await startCpuProfiling({ autoStopMs: 80 });
|
|
111
|
+
expect(started).not.toBeInstanceOf(Error);
|
|
112
|
+
await waitUntil(() => listProfiles(dataDir).length === 1);
|
|
113
|
+
expect(isCpuProfiling()).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
test('flush waits for an in-flight start and writes one profile', async () => {
|
|
116
|
+
const dataDir = makeDataDir();
|
|
117
|
+
const starting = startCpuProfiling({ autoStopMs: 10_000 });
|
|
118
|
+
const flushing = flushCpuProfiling();
|
|
119
|
+
await starting;
|
|
120
|
+
const result = await flushing;
|
|
121
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
122
|
+
expect(listProfiles(dataDir)).toHaveLength(1);
|
|
123
|
+
expect(isCpuProfiling()).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe.sequential('stdin cpuprof', () => {
|
|
127
|
+
test('handleStdinLine starts and stops on cpuprof', async () => {
|
|
128
|
+
const dataDir = makeDataDir();
|
|
129
|
+
const first = await handleStdinLine('cpuprof', { autoStopMs: 10_000 });
|
|
130
|
+
expect(first).not.toBeInstanceOf(Error);
|
|
131
|
+
expect(first).not.toBeNull();
|
|
132
|
+
if (first instanceof Error || !first)
|
|
133
|
+
return;
|
|
134
|
+
expect(first.action).toBe('started');
|
|
135
|
+
burnCpu(30);
|
|
136
|
+
const second = await handleStdinLine('cpuprof', { autoStopMs: 10_000 });
|
|
137
|
+
expect(second).not.toBeInstanceOf(Error);
|
|
138
|
+
expect(second).not.toBeNull();
|
|
139
|
+
if (second instanceof Error || !second)
|
|
140
|
+
return;
|
|
141
|
+
expect(second.action).toBe('stopped');
|
|
142
|
+
expect(listProfiles(dataDir)).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
test('handleStdinLine ignores unrelated input', async () => {
|
|
145
|
+
expect(await handleStdinLine('help')).toBeNull();
|
|
146
|
+
expect(isCpuProfiling()).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
test('stdin listener toggles on cpuprof lines', async () => {
|
|
149
|
+
const dataDir = makeDataDir();
|
|
150
|
+
const stdin = new PassThrough();
|
|
151
|
+
startStdinCpuProfListener({ stdin, autoStopMs: 10_000 });
|
|
152
|
+
stdin.write('cpuprof\n');
|
|
153
|
+
await waitUntil(() => isCpuProfiling());
|
|
154
|
+
burnCpu(30);
|
|
155
|
+
stdin.write('cpuprof\n');
|
|
156
|
+
await waitUntil(() => !isCpuProfiling());
|
|
157
|
+
expect(listProfiles(dataDir)).toHaveLength(1);
|
|
158
|
+
stopStdinCpuProfListener();
|
|
159
|
+
});
|
|
160
|
+
});
|
package/dist/database.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
3
|
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as errore from 'errore';
|
|
5
8
|
import * as orm from 'drizzle-orm';
|
|
6
9
|
import { getDb, closeDb } from './db.js';
|
|
7
10
|
import { createLogger, LogPrefix } from './logger.js';
|
|
@@ -324,6 +327,12 @@ export async function getSessionStartSourcesBySessionIds(sessionIds) {
|
|
|
324
327
|
}
|
|
325
328
|
return new Map(rows.map((row) => [row.session_id, row]));
|
|
326
329
|
}
|
|
330
|
+
export async function getSessionStartSource({ sessionId }) {
|
|
331
|
+
const db = await getDb();
|
|
332
|
+
return await db.query.session_start_sources.findFirst({
|
|
333
|
+
where: { session_id: sessionId },
|
|
334
|
+
}) ?? null;
|
|
335
|
+
}
|
|
327
336
|
export async function getChannelModel(channelId) {
|
|
328
337
|
const db = await getDb();
|
|
329
338
|
const row = await db.query.channel_models.findFirst({ where: { channel_id: channelId } });
|
|
@@ -792,6 +801,25 @@ export async function findChannelsByDirectory({ directory, channelType }) {
|
|
|
792
801
|
: undefined;
|
|
793
802
|
return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } });
|
|
794
803
|
}
|
|
804
|
+
function canonicalizeDirectory(directory) {
|
|
805
|
+
const resolved = path.resolve(directory);
|
|
806
|
+
const real = errore.try(() => fs.realpathSync(resolved), (e) => new Error('realpath failed', { cause: e }));
|
|
807
|
+
if (real instanceof Error)
|
|
808
|
+
return resolved;
|
|
809
|
+
return real;
|
|
810
|
+
}
|
|
811
|
+
export async function findRegisteredTextChannelForDirectory(directory) {
|
|
812
|
+
const wanted = canonicalizeDirectory(directory);
|
|
813
|
+
const channels = await findChannelsByDirectory({ channelType: 'text' });
|
|
814
|
+
return channels.find((ch) => canonicalizeDirectory(ch.directory) === wanted) ?? null;
|
|
815
|
+
}
|
|
816
|
+
export function formatProjectAlreadyRegisteredError({ channelId, directory, }) {
|
|
817
|
+
return [
|
|
818
|
+
`Channel already exists for this directory: ${directory}`,
|
|
819
|
+
`Channel ID: ${channelId}`,
|
|
820
|
+
`Remove the mapping first: kimaki project remove ${channelId}`,
|
|
821
|
+
].join('\n');
|
|
822
|
+
}
|
|
795
823
|
export async function getAllTextChannelDirectories() {
|
|
796
824
|
const db = await getDb();
|
|
797
825
|
const rows = await db.query.channel_directories.findMany({ where: { channel_type: 'text' }, columns: { directory: true } });
|
package/dist/db.js
CHANGED
|
@@ -165,6 +165,17 @@ async function migrateSchema({ db, client, }) {
|
|
|
165
165
|
for (const stmt of migrationStatements) {
|
|
166
166
|
await client.execute(stmt).catch(() => undefined);
|
|
167
167
|
}
|
|
168
|
+
// Existing part_messages tables never get a rewritten FK. Drop orphans.
|
|
169
|
+
// NOT EXISTS, not NOT IN: a NULL thread_sessions.thread_id would make
|
|
170
|
+
// NOT IN unknown for every row and skip the whole cleanup.
|
|
171
|
+
await client.execute(`
|
|
172
|
+
DELETE FROM part_messages
|
|
173
|
+
WHERE NOT EXISTS (
|
|
174
|
+
SELECT 1
|
|
175
|
+
FROM thread_sessions
|
|
176
|
+
WHERE thread_sessions.thread_id = part_messages.thread_id
|
|
177
|
+
)
|
|
178
|
+
`);
|
|
168
179
|
await dropLegacySessionSleepsColumns(client);
|
|
169
180
|
// Migrate legacy thread_worktrees rows into thread_workspaces.
|
|
170
181
|
// Rows that already exist in thread_workspaces (by thread_id) are skipped.
|