@brianli/kimaki 0.4.72-brianli.1
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/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// /context-usage command - Show token usage and context window percentage for the current session.
|
|
2
|
+
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
|
+
import { getThreadSession } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
import * as errore from 'errore';
|
|
8
|
+
const logger = createLogger(LogPrefix.SESSION);
|
|
9
|
+
function getTokenTotal({ input, output, reasoning, cache, }) {
|
|
10
|
+
return input + output + reasoning + cache.read + cache.write;
|
|
11
|
+
}
|
|
12
|
+
export async function handleContextUsageCommand({ command, }) {
|
|
13
|
+
const channel = command.channel;
|
|
14
|
+
if (!channel) {
|
|
15
|
+
await command.reply({
|
|
16
|
+
content: 'This command can only be used in a channel',
|
|
17
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const isThread = [
|
|
22
|
+
ChannelType.PublicThread,
|
|
23
|
+
ChannelType.PrivateThread,
|
|
24
|
+
ChannelType.AnnouncementThread,
|
|
25
|
+
].includes(channel.type);
|
|
26
|
+
if (!isThread) {
|
|
27
|
+
await command.reply({
|
|
28
|
+
content: 'This command can only be used in a thread with an active session',
|
|
29
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const resolved = await resolveWorkingDirectory({
|
|
34
|
+
channel: channel,
|
|
35
|
+
});
|
|
36
|
+
if (!resolved) {
|
|
37
|
+
await command.reply({
|
|
38
|
+
content: 'Could not determine project directory for this channel',
|
|
39
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const { projectDirectory, workingDirectory } = resolved;
|
|
44
|
+
const sessionId = await getThreadSession(channel.id);
|
|
45
|
+
if (!sessionId) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'No active session in this thread',
|
|
48
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
53
|
+
if (getClient instanceof Error) {
|
|
54
|
+
await command.reply({
|
|
55
|
+
content: `Failed to get context usage: ${getClient.message}`,
|
|
56
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
61
|
+
try {
|
|
62
|
+
const messagesResponse = await getClient().session.messages({
|
|
63
|
+
sessionID: sessionId,
|
|
64
|
+
directory: workingDirectory,
|
|
65
|
+
});
|
|
66
|
+
const messages = messagesResponse.data || [];
|
|
67
|
+
const assistantMessages = messages.filter((m) => m.info.role === 'assistant');
|
|
68
|
+
if (assistantMessages.length === 0) {
|
|
69
|
+
await command.editReply({
|
|
70
|
+
content: 'No assistant messages in this session yet',
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const lastAssistant = [...assistantMessages].reverse().find((m) => {
|
|
75
|
+
if (m.info.role !== 'assistant') {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (!('tokens' in m.info) || !m.info.tokens) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return getTokenTotal(m.info.tokens) > 0;
|
|
82
|
+
});
|
|
83
|
+
if (!lastAssistant || lastAssistant.info.role !== 'assistant') {
|
|
84
|
+
await command.editReply({
|
|
85
|
+
content: 'Token usage not available for this session yet',
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const { tokens, modelID, providerID } = lastAssistant.info;
|
|
90
|
+
const totalTokens = getTokenTotal(tokens);
|
|
91
|
+
// Sum cost across all assistant messages for accurate session total
|
|
92
|
+
// (AssistantMessage.cost is per-message, not cumulative)
|
|
93
|
+
const totalCost = assistantMessages.reduce((sum, m) => {
|
|
94
|
+
if (m.info.role === 'assistant' && 'cost' in m.info) {
|
|
95
|
+
return sum + (m.info.cost || 0);
|
|
96
|
+
}
|
|
97
|
+
return sum;
|
|
98
|
+
}, 0);
|
|
99
|
+
// Fetch model context limit from provider API
|
|
100
|
+
let contextLimit;
|
|
101
|
+
const providersResult = await errore.tryAsync(() => {
|
|
102
|
+
return getClient().provider.list({ directory: workingDirectory });
|
|
103
|
+
});
|
|
104
|
+
if (providersResult instanceof Error) {
|
|
105
|
+
logger.error('[CONTEXT-USAGE] Failed to fetch provider info:', providersResult);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const provider = providersResult.data?.all?.find((p) => p.id === providerID);
|
|
109
|
+
const model = provider?.models?.[modelID];
|
|
110
|
+
if (model?.limit?.context) {
|
|
111
|
+
contextLimit = model.limit.context;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const formattedTokens = totalTokens.toLocaleString('en-US');
|
|
115
|
+
const formattedCost = totalCost > 0 ? `$${totalCost.toFixed(4)}` : '$0.00';
|
|
116
|
+
const lines = [];
|
|
117
|
+
if (contextLimit) {
|
|
118
|
+
const percentage = Math.round((totalTokens / contextLimit) * 100);
|
|
119
|
+
const formattedLimit = contextLimit.toLocaleString('en-US');
|
|
120
|
+
lines.push(`**Context usage:** ${percentage}%, ${formattedTokens} / ${formattedLimit} tokens`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
lines.push(`**Context usage:** ${formattedTokens} tokens (context limit unavailable)`);
|
|
124
|
+
}
|
|
125
|
+
if (modelID) {
|
|
126
|
+
lines.push(`**Model:** ${modelID}`);
|
|
127
|
+
}
|
|
128
|
+
if (totalCost > 0) {
|
|
129
|
+
lines.push(`**Session cost:** ${formattedCost}`);
|
|
130
|
+
}
|
|
131
|
+
await command.editReply({ content: lines.join('\n') });
|
|
132
|
+
logger.log(`Context usage shown for session ${sessionId}: ${totalTokens} tokens`);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
logger.error('[CONTEXT-USAGE] Error:', error);
|
|
136
|
+
await command.editReply({
|
|
137
|
+
content: `Failed to get context usage: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// /create-new-project command - Create a new project folder, initialize git, and start a session.
|
|
2
|
+
// Also exports createNewProject() for reuse during onboarding (welcome channel creation).
|
|
3
|
+
import { ChannelType } from 'discord.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { getProjectsDir } from '../config.js';
|
|
8
|
+
import { createProjectChannels } from '../channel-management.js';
|
|
9
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
10
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
11
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
|
+
const logger = createLogger(LogPrefix.CREATE_PROJECT);
|
|
13
|
+
/**
|
|
14
|
+
* Core project creation logic: creates directory, inits git, creates Discord channels.
|
|
15
|
+
* Reused by the slash command handler and by onboarding (welcome channel).
|
|
16
|
+
* Returns null if the project directory already exists.
|
|
17
|
+
*/
|
|
18
|
+
export async function createNewProject({ guild, projectName, appId, botName, }) {
|
|
19
|
+
const sanitizedName = projectName
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
22
|
+
.replace(/-+/g, '-')
|
|
23
|
+
.replace(/^-|-$/g, '')
|
|
24
|
+
.slice(0, 100);
|
|
25
|
+
if (!sanitizedName) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const projectsDir = getProjectsDir();
|
|
29
|
+
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
30
|
+
if (!fs.existsSync(projectsDir)) {
|
|
31
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
32
|
+
logger.log(`Created projects directory: ${projectsDir}`);
|
|
33
|
+
}
|
|
34
|
+
if (fs.existsSync(projectDirectory)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
38
|
+
logger.log(`Created project directory: ${projectDirectory}`);
|
|
39
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
40
|
+
logger.log(`Initialized git in: ${projectDirectory}`);
|
|
41
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
42
|
+
guild,
|
|
43
|
+
projectDirectory,
|
|
44
|
+
appId,
|
|
45
|
+
botName,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
textChannelId,
|
|
49
|
+
voiceChannelId,
|
|
50
|
+
channelName,
|
|
51
|
+
projectDirectory,
|
|
52
|
+
sanitizedName,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
56
|
+
await command.deferReply({ ephemeral: false });
|
|
57
|
+
const projectName = command.options.getString('name', true);
|
|
58
|
+
const guild = command.guild;
|
|
59
|
+
const channel = command.channel;
|
|
60
|
+
if (!guild) {
|
|
61
|
+
await command.editReply('This command can only be used in a guild');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
65
|
+
await command.editReply('This command can only be used in a text channel');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const result = await createNewProject({
|
|
70
|
+
guild,
|
|
71
|
+
projectName,
|
|
72
|
+
appId,
|
|
73
|
+
botName: command.client.user?.username,
|
|
74
|
+
});
|
|
75
|
+
if (!result) {
|
|
76
|
+
const sanitizedName = projectName
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
79
|
+
.replace(/-+/g, '-')
|
|
80
|
+
.replace(/^-|-$/g, '')
|
|
81
|
+
.slice(0, 100);
|
|
82
|
+
if (!sanitizedName) {
|
|
83
|
+
await command.editReply('Invalid project name');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const projectDirectory = path.join(getProjectsDir(), sanitizedName);
|
|
87
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName, } = result;
|
|
91
|
+
const textChannel = (await guild.channels.fetch(textChannelId));
|
|
92
|
+
const voiceInfo = voiceChannelId ? `\n🔊 Voice: <#${voiceChannelId}>` : '';
|
|
93
|
+
await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`);
|
|
94
|
+
const starterMessage = await textChannel.send({
|
|
95
|
+
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
96
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
97
|
+
});
|
|
98
|
+
const thread = await starterMessage.startThread({
|
|
99
|
+
name: `Init: ${sanitizedName}`,
|
|
100
|
+
autoArchiveDuration: 1440,
|
|
101
|
+
reason: 'New project session',
|
|
102
|
+
});
|
|
103
|
+
// Add user to thread so it appears in their sidebar
|
|
104
|
+
await thread.members.add(command.user.id);
|
|
105
|
+
await handleOpencodeSession({
|
|
106
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
107
|
+
thread,
|
|
108
|
+
projectDirectory,
|
|
109
|
+
channelId: textChannel.id,
|
|
110
|
+
appId,
|
|
111
|
+
});
|
|
112
|
+
logger.log(`Created new project ${channelName} at ${projectDirectory}`);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
logger.error('[CREATE-NEW-PROJECT] Error:', error);
|
|
116
|
+
await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// /diff command - Show git diff as a shareable URL.
|
|
2
|
+
import { ChannelType, EmbedBuilder, MessageFlags, } from 'discord.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
5
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
6
|
+
import { execAsync } from '../worktree-utils.js';
|
|
7
|
+
const logger = createLogger(LogPrefix.DIFF);
|
|
8
|
+
export async function handleDiffCommand({ command, }) {
|
|
9
|
+
const channel = command.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await command.reply({
|
|
12
|
+
content: 'This command can only be used in a channel',
|
|
13
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
14
|
+
});
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const isThread = [
|
|
18
|
+
ChannelType.PublicThread,
|
|
19
|
+
ChannelType.PrivateThread,
|
|
20
|
+
ChannelType.AnnouncementThread,
|
|
21
|
+
].includes(channel.type);
|
|
22
|
+
const isTextChannel = channel.type === ChannelType.GuildText;
|
|
23
|
+
if (!isThread && !isTextChannel) {
|
|
24
|
+
await command.reply({
|
|
25
|
+
content: 'This command can only be used in a text channel or thread',
|
|
26
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const resolved = await resolveWorkingDirectory({
|
|
31
|
+
channel: channel,
|
|
32
|
+
});
|
|
33
|
+
if (!resolved) {
|
|
34
|
+
await command.reply({
|
|
35
|
+
content: 'Could not determine project directory for this channel',
|
|
36
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { workingDirectory } = resolved;
|
|
41
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
42
|
+
try {
|
|
43
|
+
const projectName = path.basename(workingDirectory);
|
|
44
|
+
const title = `${projectName}: Discord /diff`;
|
|
45
|
+
const { stdout, stderr } = await execAsync(`bunx critique --web "${title}" --json`, {
|
|
46
|
+
cwd: workingDirectory,
|
|
47
|
+
timeout: 30000,
|
|
48
|
+
});
|
|
49
|
+
// critique --json outputs JSON on the last line: {"url":"...","id":"..."} or {"error":"..."}
|
|
50
|
+
const output = stdout || stderr;
|
|
51
|
+
const lines = output.trim().split('\n');
|
|
52
|
+
const jsonLine = lines[lines.length - 1];
|
|
53
|
+
if (!jsonLine) {
|
|
54
|
+
await command.editReply({
|
|
55
|
+
content: 'No changes to show',
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
result = JSON.parse(jsonLine);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Fallback: try to find URL in output
|
|
65
|
+
const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/);
|
|
66
|
+
if (urlMatch) {
|
|
67
|
+
await command.editReply({
|
|
68
|
+
content: `[diff](${urlMatch[0]})`,
|
|
69
|
+
});
|
|
70
|
+
logger.log(`Diff shared: ${urlMatch[0]}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await command.editReply({
|
|
74
|
+
content: 'No changes to show',
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (result.error || !result.url || !result.id) {
|
|
79
|
+
await command.editReply({
|
|
80
|
+
content: result.error || 'No changes to show',
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const imageUrl = `https://critique.work/og/${result.id}.png`;
|
|
85
|
+
const embed = new EmbedBuilder()
|
|
86
|
+
.setTitle(title)
|
|
87
|
+
.setURL(result.url)
|
|
88
|
+
.setImage(imageUrl);
|
|
89
|
+
await command.editReply({
|
|
90
|
+
embeds: [embed],
|
|
91
|
+
});
|
|
92
|
+
logger.log(`Diff shared: ${result.url}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error('[DIFF] Error:', error);
|
|
96
|
+
// exec error includes stdout/stderr - try to parse JSON from it
|
|
97
|
+
const execError = error;
|
|
98
|
+
const output = execError.stdout || execError.stderr || '';
|
|
99
|
+
// Check if critique output JSON even on error
|
|
100
|
+
const lines = output.trim().split('\n');
|
|
101
|
+
const jsonLine = lines[lines.length - 1];
|
|
102
|
+
if (jsonLine) {
|
|
103
|
+
try {
|
|
104
|
+
const result = JSON.parse(jsonLine);
|
|
105
|
+
if (result.error) {
|
|
106
|
+
await command.editReply({
|
|
107
|
+
content: result.error,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// not JSON, continue to generic error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Check for common errors
|
|
117
|
+
const message = execError.message || 'Unknown error';
|
|
118
|
+
if (message.includes('command not found') || message.includes('ENOENT')) {
|
|
119
|
+
await command.editReply({
|
|
120
|
+
content: 'bunx/critique not available',
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await command.editReply({
|
|
125
|
+
content: `Failed to generate diff: ${message.slice(0, 200)}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// File upload tool handler - Shows Discord modal with FileUploadBuilder.
|
|
2
|
+
// When the AI uses the kimaki_file_upload tool, the plugin inserts a row into
|
|
3
|
+
// the ipc_requests DB table. The bot polls this table, picks up the request,
|
|
4
|
+
// and shows a button in the thread. User clicks it to open a modal with a
|
|
5
|
+
// native file picker. Uploaded files are downloaded to the project directory.
|
|
6
|
+
// The bot writes file paths back to ipc_requests.response, and the plugin
|
|
7
|
+
// polls until the response appears.
|
|
8
|
+
import { ButtonBuilder, ButtonStyle, ActionRowBuilder, ModalBuilder, FileUploadBuilder, LabelBuilder, ComponentType, MessageFlags, } from 'discord.js';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
|
+
import { notifyError } from '../sentry.js';
|
|
14
|
+
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
15
|
+
const logger = createLogger(LogPrefix.FILE_UPLOAD);
|
|
16
|
+
// 5 minute TTL for pending contexts - if user doesn't click within this time,
|
|
17
|
+
// clean up the context and resolve with empty array to unblock the plugin tool
|
|
18
|
+
const PENDING_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
export const pendingFileUploadContexts = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* Sanitize an attachment filename to prevent path traversal.
|
|
22
|
+
* Strips directory separators, .., and null bytes from the name.
|
|
23
|
+
* Prepends a short random prefix to avoid collisions between uploads.
|
|
24
|
+
*/
|
|
25
|
+
function sanitizeFilename(name) {
|
|
26
|
+
// Extract just the base name (strips any directory components)
|
|
27
|
+
let sanitized = path.basename(name);
|
|
28
|
+
// Remove null bytes and other dangerous characters
|
|
29
|
+
sanitized = sanitized.replace(/[\x00]/g, '');
|
|
30
|
+
// If somehow still empty or just dots, give it a safe name
|
|
31
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
32
|
+
sanitized = 'upload';
|
|
33
|
+
}
|
|
34
|
+
// Prefix with short random id to avoid collisions
|
|
35
|
+
const prefix = crypto.randomBytes(4).toString('hex');
|
|
36
|
+
return `${prefix}-${sanitized}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Safely resolve a pending context exactly once. Prevents double-resolve from
|
|
40
|
+
* cancel/submit races by checking the `resolved` flag.
|
|
41
|
+
*/
|
|
42
|
+
function resolveContext(context, filePaths) {
|
|
43
|
+
if (context.resolved) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
context.resolved = true;
|
|
47
|
+
clearTimeout(context.timer);
|
|
48
|
+
pendingFileUploadContexts.delete(context.contextHash);
|
|
49
|
+
context.resolve(filePaths);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Show a button in the thread that opens a file upload modal when clicked.
|
|
54
|
+
* Returns a promise that resolves with the downloaded file paths.
|
|
55
|
+
*/
|
|
56
|
+
export function showFileUploadButton({ thread, sessionId, directory, prompt, maxFiles, }) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
59
|
+
// TTL timer: auto-cleanup if user never clicks the button
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
const ctx = pendingFileUploadContexts.get(contextHash);
|
|
62
|
+
if (ctx && !ctx.resolved) {
|
|
63
|
+
logger.log(`File upload timed out for session ${sessionId}, hash=${contextHash}`);
|
|
64
|
+
resolveContext(ctx, []);
|
|
65
|
+
// Remove button from message
|
|
66
|
+
if (ctx.messageId) {
|
|
67
|
+
ctx.thread.messages
|
|
68
|
+
.fetch(ctx.messageId)
|
|
69
|
+
.then((msg) => {
|
|
70
|
+
return msg.edit({
|
|
71
|
+
content: `**File Upload Requested**\n${prompt.slice(0, 1900)}\n_Timed out_`,
|
|
72
|
+
components: [],
|
|
73
|
+
});
|
|
74
|
+
})
|
|
75
|
+
.catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, PENDING_TTL_MS);
|
|
79
|
+
const context = {
|
|
80
|
+
sessionId,
|
|
81
|
+
directory,
|
|
82
|
+
thread,
|
|
83
|
+
prompt,
|
|
84
|
+
maxFiles,
|
|
85
|
+
contextHash,
|
|
86
|
+
resolve,
|
|
87
|
+
reject,
|
|
88
|
+
resolved: false,
|
|
89
|
+
timer,
|
|
90
|
+
};
|
|
91
|
+
pendingFileUploadContexts.set(contextHash, context);
|
|
92
|
+
const uploadButton = new ButtonBuilder()
|
|
93
|
+
.setCustomId(`file_upload_btn:${contextHash}`)
|
|
94
|
+
.setLabel('Upload Files')
|
|
95
|
+
.setStyle(ButtonStyle.Primary);
|
|
96
|
+
const actionRow = new ActionRowBuilder().addComponents(uploadButton);
|
|
97
|
+
thread
|
|
98
|
+
.send({
|
|
99
|
+
content: `**File Upload Requested**\n${prompt.slice(0, 1900)}`,
|
|
100
|
+
components: [actionRow],
|
|
101
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
102
|
+
})
|
|
103
|
+
.then((msg) => {
|
|
104
|
+
context.messageId = msg.id;
|
|
105
|
+
logger.log(`Showed file upload button for session ${sessionId}, hash=${contextHash}`);
|
|
106
|
+
})
|
|
107
|
+
.catch((err) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
pendingFileUploadContexts.delete(contextHash);
|
|
110
|
+
reject(new Error('Failed to send file upload button', { cause: err }));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Handle the file upload button click - opens a modal with FileUploadBuilder.
|
|
116
|
+
*/
|
|
117
|
+
export async function handleFileUploadButton(interaction) {
|
|
118
|
+
const customId = interaction.customId;
|
|
119
|
+
if (!customId.startsWith('file_upload_btn:')) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const contextHash = customId.replace('file_upload_btn:', '');
|
|
123
|
+
const context = pendingFileUploadContexts.get(contextHash);
|
|
124
|
+
if (!context || context.resolved) {
|
|
125
|
+
await interaction.reply({
|
|
126
|
+
content: 'This file upload request has expired.',
|
|
127
|
+
flags: MessageFlags.Ephemeral,
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const fileUpload = new FileUploadBuilder()
|
|
132
|
+
.setCustomId('uploaded_files')
|
|
133
|
+
.setMinValues(1)
|
|
134
|
+
.setMaxValues(context.maxFiles);
|
|
135
|
+
const label = new LabelBuilder()
|
|
136
|
+
.setLabel('Files')
|
|
137
|
+
.setDescription(context.prompt.slice(0, 100))
|
|
138
|
+
.setFileUploadComponent(fileUpload);
|
|
139
|
+
const modal = new ModalBuilder()
|
|
140
|
+
.setCustomId(`file_upload_modal:${contextHash}`)
|
|
141
|
+
.setTitle('Upload Files')
|
|
142
|
+
.addLabelComponents(label);
|
|
143
|
+
await interaction.showModal(modal);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Handle the modal submission - download files and resolve the pending promise.
|
|
147
|
+
*/
|
|
148
|
+
export async function handleFileUploadModalSubmit(interaction) {
|
|
149
|
+
const customId = interaction.customId;
|
|
150
|
+
if (!customId.startsWith('file_upload_modal:')) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const contextHash = customId.replace('file_upload_modal:', '');
|
|
154
|
+
const context = pendingFileUploadContexts.get(contextHash);
|
|
155
|
+
if (!context || context.resolved) {
|
|
156
|
+
await interaction.reply({
|
|
157
|
+
content: 'This file upload request has expired.',
|
|
158
|
+
flags: MessageFlags.Ephemeral,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
164
|
+
// File upload data is nested in the LabelModalData -> FileUploadModalData
|
|
165
|
+
const fileField = interaction.fields.getField('uploaded_files', ComponentType.FileUpload);
|
|
166
|
+
const attachments = fileField.attachments;
|
|
167
|
+
if (!attachments || attachments.size === 0) {
|
|
168
|
+
await interaction.editReply({ content: 'No files were uploaded.' });
|
|
169
|
+
updateButtonMessage(context, '_No files uploaded_');
|
|
170
|
+
resolveContext(context, []);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const uploadsDir = path.join(context.directory, 'uploads');
|
|
174
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
175
|
+
const downloadedPaths = [];
|
|
176
|
+
const errors = [];
|
|
177
|
+
for (const [, attachment] of attachments) {
|
|
178
|
+
// Check if context was cancelled (e.g. user sent new message) while
|
|
179
|
+
// we were downloading previous files - stop downloading more
|
|
180
|
+
if (context.resolved) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch(attachment.url);
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
errors.push(`Failed to download ${attachment.name}: HTTP ${response.status}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
190
|
+
const safeName = sanitizeFilename(attachment.name);
|
|
191
|
+
const filePath = path.join(uploadsDir, safeName);
|
|
192
|
+
fs.writeFileSync(filePath, buffer);
|
|
193
|
+
downloadedPaths.push(filePath);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
197
|
+
errors.push(`Failed to download ${attachment.name}: ${msg}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// If context was resolved by cancel/timeout during download, don't try to
|
|
201
|
+
// resolve again - just update the ephemeral reply
|
|
202
|
+
if (context.resolved) {
|
|
203
|
+
await interaction.editReply({ content: 'Upload was cancelled.' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const fileNames = downloadedPaths.map((p) => {
|
|
207
|
+
return path.basename(p);
|
|
208
|
+
});
|
|
209
|
+
updateButtonMessage(context, downloadedPaths.length > 0
|
|
210
|
+
? `Uploaded: ${fileNames.join(', ')}`
|
|
211
|
+
: '_Upload failed_');
|
|
212
|
+
const summary = (() => {
|
|
213
|
+
if (downloadedPaths.length > 0 && errors.length === 0) {
|
|
214
|
+
return `Uploaded ${downloadedPaths.length} file(s) successfully.`;
|
|
215
|
+
}
|
|
216
|
+
if (downloadedPaths.length > 0 && errors.length > 0) {
|
|
217
|
+
return `Uploaded ${downloadedPaths.length} file(s). Errors: ${errors.join('; ')}`;
|
|
218
|
+
}
|
|
219
|
+
return `Upload failed: ${errors.join('; ')}`;
|
|
220
|
+
})();
|
|
221
|
+
await interaction.editReply({ content: summary });
|
|
222
|
+
resolveContext(context, downloadedPaths);
|
|
223
|
+
logger.log(`File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// Ensure context is always resolved even on unexpected errors
|
|
227
|
+
// so the plugin tool doesn't hang indefinitely
|
|
228
|
+
logger.error('Error in file upload modal submit:', err);
|
|
229
|
+
void notifyError(err, 'File upload modal submit error');
|
|
230
|
+
resolveContext(context, []);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Best-effort update of the original button message (remove button, append status).
|
|
235
|
+
*/
|
|
236
|
+
function updateButtonMessage(context, status) {
|
|
237
|
+
if (!context.messageId) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
context.thread.messages
|
|
241
|
+
.fetch(context.messageId)
|
|
242
|
+
.then((msg) => {
|
|
243
|
+
return msg.edit({
|
|
244
|
+
content: `**File Upload Requested**\n${context.prompt.slice(0, 1900)}\n${status}`,
|
|
245
|
+
components: [],
|
|
246
|
+
});
|
|
247
|
+
})
|
|
248
|
+
.catch(() => { });
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Cancel ALL pending file uploads for a thread (e.g. when user sends a new message).
|
|
252
|
+
*/
|
|
253
|
+
export async function cancelPendingFileUpload(threadId) {
|
|
254
|
+
const toCancel = [];
|
|
255
|
+
for (const [, ctx] of pendingFileUploadContexts) {
|
|
256
|
+
if (ctx.thread.id === threadId) {
|
|
257
|
+
toCancel.push(ctx);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (toCancel.length === 0) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
let cancelled = 0;
|
|
264
|
+
for (const context of toCancel) {
|
|
265
|
+
const didResolve = resolveContext(context, []);
|
|
266
|
+
if (didResolve) {
|
|
267
|
+
updateButtonMessage(context, '_Cancelled - user sent a new message_');
|
|
268
|
+
cancelled++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (cancelled > 0) {
|
|
272
|
+
logger.log(`Cancelled ${cancelled} file upload(s) for thread ${threadId}`);
|
|
273
|
+
}
|
|
274
|
+
return cancelled > 0;
|
|
275
|
+
}
|