disunday 1.0.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/ai-tool-to-genai.js +208 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +96 -0
- package/dist/cli.js +1674 -0
- package/dist/commands/abort.js +89 -0
- package/dist/commands/add-project.js +117 -0
- package/dist/commands/agent.js +250 -0
- package/dist/commands/ask-question.js +219 -0
- package/dist/commands/compact.js +126 -0
- package/dist/commands/context-menu.js +171 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/cost.js +93 -0
- package/dist/commands/create-new-project.js +111 -0
- package/dist/commands/diff.js +77 -0
- package/dist/commands/export.js +100 -0
- package/dist/commands/files.js +73 -0
- package/dist/commands/fork.js +199 -0
- package/dist/commands/help.js +54 -0
- package/dist/commands/login.js +488 -0
- package/dist/commands/merge-worktree.js +165 -0
- package/dist/commands/model.js +325 -0
- package/dist/commands/permissions.js +140 -0
- package/dist/commands/ping.js +13 -0
- package/dist/commands/queue.js +133 -0
- package/dist/commands/remove-project.js +119 -0
- package/dist/commands/rename.js +70 -0
- package/dist/commands/restart-opencode-server.js +77 -0
- package/dist/commands/resume.js +276 -0
- package/dist/commands/run-config.js +79 -0
- package/dist/commands/run.js +240 -0
- package/dist/commands/schedule.js +170 -0
- package/dist/commands/session-info.js +58 -0
- package/dist/commands/session.js +191 -0
- package/dist/commands/settings.js +84 -0
- package/dist/commands/share.js +89 -0
- package/dist/commands/status.js +79 -0
- package/dist/commands/sync.js +119 -0
- package/dist/commands/theme.js +53 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +170 -0
- package/dist/commands/user-command.js +135 -0
- package/dist/commands/verbosity.js +59 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +288 -0
- package/dist/config.js +139 -0
- package/dist/database.js +585 -0
- package/dist/discord-bot.js +700 -0
- package/dist/discord-utils.js +336 -0
- package/dist/discord-utils.test.js +20 -0
- package/dist/errors.js +193 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +299 -0
- package/dist/genai.js +230 -0
- package/dist/image-utils.js +107 -0
- package/dist/interaction-handler.js +289 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +111 -0
- package/dist/markdown.js +323 -0
- package/dist/markdown.test.js +269 -0
- package/dist/message-formatting.js +447 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +226 -0
- package/dist/opencode.js +224 -0
- package/dist/reaction-handler.js +128 -0
- package/dist/scheduler.js +93 -0
- package/dist/security.js +200 -0
- package/dist/session-handler.js +1436 -0
- package/dist/system-message.js +138 -0
- package/dist/tools.js +354 -0
- package/dist/unnest-code-blocks.js +117 -0
- package/dist/unnest-code-blocks.test.js +432 -0
- package/dist/utils.js +95 -0
- package/dist/voice-handler.js +569 -0
- package/dist/voice.js +344 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +134 -0
- package/dist/xml.js +90 -0
- package/dist/xml.test.js +32 -0
- package/package.json +84 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// OpenCode system prompt generator.
|
|
2
|
+
// Creates the system message injected into every OpenCode session,
|
|
3
|
+
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
4
|
+
export function getOpencodeSystemMessage({ sessionId, channelId, worktree, }) {
|
|
5
|
+
return `
|
|
6
|
+
The user is reading your messages from inside Discord, via disunday
|
|
7
|
+
|
|
8
|
+
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
9
|
+
|
|
10
|
+
## bash tool
|
|
11
|
+
|
|
12
|
+
When calling the bash tool, always include a boolean field \`hasSideEffect\`.
|
|
13
|
+
Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
|
|
14
|
+
Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
|
|
15
|
+
This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
|
|
16
|
+
|
|
17
|
+
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
|
|
18
|
+
|
|
19
|
+
## permissions
|
|
20
|
+
|
|
21
|
+
Only users with these Discord permissions can send messages to the bot:
|
|
22
|
+
- Server Owner
|
|
23
|
+
- Administrator permission
|
|
24
|
+
- Manage Server permission
|
|
25
|
+
- "Disunday" role (case-insensitive)
|
|
26
|
+
|
|
27
|
+
## uploading files to discord
|
|
28
|
+
|
|
29
|
+
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
30
|
+
|
|
31
|
+
npx -y disunday upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
32
|
+
${channelId
|
|
33
|
+
? `
|
|
34
|
+
## starting new sessions from CLI
|
|
35
|
+
|
|
36
|
+
To start a new thread/session in this channel programmatically, run:
|
|
37
|
+
|
|
38
|
+
npx -y disunday send --channel ${channelId} --prompt "your prompt here"
|
|
39
|
+
|
|
40
|
+
Use --notify-only to create a notification thread without starting an AI session:
|
|
41
|
+
|
|
42
|
+
npx -y disunday send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
|
|
43
|
+
|
|
44
|
+
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
45
|
+
|
|
46
|
+
### Session handoff
|
|
47
|
+
|
|
48
|
+
When you are approaching the **context window limit** or the user explicitly asks to **handoff to a new thread**, use the \`disunday send\` command to start a fresh session with context:
|
|
49
|
+
|
|
50
|
+
\`\`\`bash
|
|
51
|
+
npx -y disunday send --channel ${channelId} --prompt "Continuing from previous session: <summary of current task and state>"
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
The command automatically handles long prompts (over 2000 chars) by sending them as file attachments.
|
|
55
|
+
|
|
56
|
+
Use this for handoff when:
|
|
57
|
+
- User asks to "handoff", "continue in new thread", or "start fresh session"
|
|
58
|
+
- You detect you're running low on context window space
|
|
59
|
+
- A complex task would benefit from a clean slate with summarized context
|
|
60
|
+
`
|
|
61
|
+
: ''}${worktree
|
|
62
|
+
? `
|
|
63
|
+
## worktree
|
|
64
|
+
|
|
65
|
+
This session is running inside a git worktree.
|
|
66
|
+
- **Worktree path:** \`${worktree.worktreeDirectory}\`
|
|
67
|
+
- **Branch:** \`${worktree.branch}\`
|
|
68
|
+
- **Main repo:** \`${worktree.mainRepoDirectory}\`
|
|
69
|
+
|
|
70
|
+
Before finishing a task, ask the user if they want to merge changes back to the main branch.
|
|
71
|
+
|
|
72
|
+
To merge (without leaving the worktree):
|
|
73
|
+
\`\`\`bash
|
|
74
|
+
# Get the default branch name
|
|
75
|
+
DEFAULT_BRANCH=$(git -C ${worktree.mainRepoDirectory} symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
|
|
76
|
+
|
|
77
|
+
# Merge worktree branch into main
|
|
78
|
+
git -C ${worktree.mainRepoDirectory} checkout $DEFAULT_BRANCH && git -C ${worktree.mainRepoDirectory} merge ${worktree.branch}
|
|
79
|
+
\`\`\`
|
|
80
|
+
`
|
|
81
|
+
: ''}
|
|
82
|
+
## showing diffs
|
|
83
|
+
|
|
84
|
+
IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
|
|
85
|
+
|
|
86
|
+
Execute this after making changes:
|
|
87
|
+
|
|
88
|
+
bunx critique --web "Add user authentication flow"
|
|
89
|
+
|
|
90
|
+
If there are other unrelated changes in the working directory, filter to only show the files you edited:
|
|
91
|
+
|
|
92
|
+
bunx critique --web "Fix database connection retry" --filter "path/to/file1.ts" --filter "path/to/file2.ts"
|
|
93
|
+
|
|
94
|
+
You can also show latest commit changes using:
|
|
95
|
+
|
|
96
|
+
bunx critique HEAD --web "Refactor API endpoints"
|
|
97
|
+
|
|
98
|
+
bunx critique HEAD~1 --web "Update dependencies"
|
|
99
|
+
|
|
100
|
+
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
101
|
+
|
|
102
|
+
To compare two branches:
|
|
103
|
+
|
|
104
|
+
bunx critique main feature-branch --web "Compare branches"
|
|
105
|
+
|
|
106
|
+
The command outputs a URL - share that URL with the user so they can see the diff.
|
|
107
|
+
|
|
108
|
+
## markdown
|
|
109
|
+
|
|
110
|
+
discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
|
|
111
|
+
|
|
112
|
+
the max heading level is 3, so do not use ####
|
|
113
|
+
|
|
114
|
+
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
## diagrams
|
|
118
|
+
|
|
119
|
+
you can create diagrams wrapping them in code blocks.
|
|
120
|
+
|
|
121
|
+
## proactivity
|
|
122
|
+
|
|
123
|
+
Be proactive. When the user asks you to do something, do it. Do NOT stop to ask for confirmation.
|
|
124
|
+
|
|
125
|
+
Only ask questions when the request is genuinely ambiguous with multiple valid approaches, or the action is destructive and irreversible.
|
|
126
|
+
|
|
127
|
+
## ending conversations with options
|
|
128
|
+
|
|
129
|
+
After **completing** a task, use the question tool to offer follow-up options. The question tool must be called last, after all text parts.
|
|
130
|
+
|
|
131
|
+
IMPORTANT: Do NOT use the question tool to ask permission before doing work. Do the work first, then offer follow-ups.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
- After completing edits: offer "Commit changes?" or "Run tests?"
|
|
135
|
+
- After debugging: offer "Apply fix", "Investigate further", "Try different approach"
|
|
136
|
+
- After a genuinely ambiguous request where you cannot infer intent: offer the different approaches
|
|
137
|
+
`;
|
|
138
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Voice assistant tool definitions for the GenAI worker.
|
|
2
|
+
// Provides tools for managing OpenCode sessions (create, submit, abort),
|
|
3
|
+
// listing chats, searching files, and reading session messages.
|
|
4
|
+
import { tool } from 'ai';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
|
9
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
10
|
+
import * as errore from 'errore';
|
|
11
|
+
const toolsLogger = createLogger(LogPrefix.TOOLS);
|
|
12
|
+
import { ShareMarkdown } from './markdown.js';
|
|
13
|
+
import { formatDistanceToNow } from './utils.js';
|
|
14
|
+
import pc from 'picocolors';
|
|
15
|
+
import { initializeOpencodeForDirectory, getOpencodeSystemMessage } from './discord-bot.js';
|
|
16
|
+
export async function getTools({ onMessageCompleted, directory, }) {
|
|
17
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
18
|
+
if (getClient instanceof Error) {
|
|
19
|
+
throw new Error(getClient.message);
|
|
20
|
+
}
|
|
21
|
+
const client = getClient();
|
|
22
|
+
const markdownRenderer = new ShareMarkdown(client);
|
|
23
|
+
const providersResponse = await client.config.providers({});
|
|
24
|
+
const providers = providersResponse.data?.providers || [];
|
|
25
|
+
// Helper: get last assistant model for a session (non-summary)
|
|
26
|
+
const getSessionModel = async (sessionId) => {
|
|
27
|
+
const res = await getClient().session.messages({ path: { id: sessionId } });
|
|
28
|
+
const data = res.data;
|
|
29
|
+
if (!data || data.length === 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
32
|
+
const info = data?.[i]?.info;
|
|
33
|
+
if (info?.role === 'assistant') {
|
|
34
|
+
const ai = info;
|
|
35
|
+
if (!ai.summary && ai.providerID && ai.modelID) {
|
|
36
|
+
return { providerID: ai.providerID, modelID: ai.modelID };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
const tools = {
|
|
43
|
+
submitMessage: tool({
|
|
44
|
+
description: 'Submit a message to an existing chat session. Does not wait for the message to complete',
|
|
45
|
+
inputSchema: z.object({
|
|
46
|
+
sessionId: z.string().describe('The session ID to send message to'),
|
|
47
|
+
message: z.string().describe('The message text to send'),
|
|
48
|
+
}),
|
|
49
|
+
execute: async ({ sessionId, message }) => {
|
|
50
|
+
const sessionModel = await getSessionModel(sessionId);
|
|
51
|
+
// do not await
|
|
52
|
+
getClient()
|
|
53
|
+
.session.prompt({
|
|
54
|
+
path: { id: sessionId },
|
|
55
|
+
body: {
|
|
56
|
+
parts: [{ type: 'text', text: message }],
|
|
57
|
+
model: sessionModel,
|
|
58
|
+
system: getOpencodeSystemMessage({ sessionId }),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
.then(async (response) => {
|
|
62
|
+
const markdownResult = await markdownRenderer.generate({
|
|
63
|
+
sessionID: sessionId,
|
|
64
|
+
lastAssistantOnly: true,
|
|
65
|
+
});
|
|
66
|
+
onMessageCompleted?.({
|
|
67
|
+
sessionId,
|
|
68
|
+
messageId: '',
|
|
69
|
+
data: response.data,
|
|
70
|
+
markdown: errore.unwrapOr(markdownResult, ''),
|
|
71
|
+
});
|
|
72
|
+
})
|
|
73
|
+
.catch((error) => {
|
|
74
|
+
onMessageCompleted?.({
|
|
75
|
+
sessionId,
|
|
76
|
+
messageId: '',
|
|
77
|
+
error,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
sessionId,
|
|
83
|
+
directive: 'Tell user that message has been sent successfully',
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
createNewChat: tool({
|
|
88
|
+
description: 'Start a new chat session with an initial message. Does not wait for the message to complete',
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
message: z.string().describe('The initial message to start the chat with'),
|
|
91
|
+
title: z.string().optional().describe('Optional title for the session'),
|
|
92
|
+
model: z
|
|
93
|
+
.object({
|
|
94
|
+
providerId: z.string().describe('The provider ID (e.g., "anthropic", "openai")'),
|
|
95
|
+
modelId: z.string().describe('The model ID (e.g., "claude-opus-4-20250514", "gpt-5")'),
|
|
96
|
+
})
|
|
97
|
+
.optional()
|
|
98
|
+
.describe('Optional model to use for this session'),
|
|
99
|
+
}),
|
|
100
|
+
execute: async ({ message, title }) => {
|
|
101
|
+
if (!message.trim()) {
|
|
102
|
+
throw new Error(`message must be a non empty string`);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const session = await getClient().session.create({
|
|
106
|
+
body: {
|
|
107
|
+
title: title || message.slice(0, 50),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
if (!session.data) {
|
|
111
|
+
throw new Error('Failed to create session');
|
|
112
|
+
}
|
|
113
|
+
// do not await
|
|
114
|
+
getClient()
|
|
115
|
+
.session.prompt({
|
|
116
|
+
path: { id: session.data.id },
|
|
117
|
+
body: {
|
|
118
|
+
parts: [{ type: 'text', text: message }],
|
|
119
|
+
system: getOpencodeSystemMessage({ sessionId: session.data.id }),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
.then(async (response) => {
|
|
123
|
+
const markdownResult = await markdownRenderer.generate({
|
|
124
|
+
sessionID: session.data.id,
|
|
125
|
+
lastAssistantOnly: true,
|
|
126
|
+
});
|
|
127
|
+
onMessageCompleted?.({
|
|
128
|
+
sessionId: session.data.id,
|
|
129
|
+
messageId: '',
|
|
130
|
+
data: response.data,
|
|
131
|
+
markdown: errore.unwrapOr(markdownResult, ''),
|
|
132
|
+
});
|
|
133
|
+
})
|
|
134
|
+
.catch((error) => {
|
|
135
|
+
onMessageCompleted?.({
|
|
136
|
+
sessionId: session.data.id,
|
|
137
|
+
messageId: '',
|
|
138
|
+
error,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
sessionId: session.data.id,
|
|
144
|
+
title: session.data.title,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: error instanceof Error ? error.message : 'Failed to create chat session',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
listChats: tool({
|
|
156
|
+
description: 'Get a list of available chat sessions sorted by most recent',
|
|
157
|
+
inputSchema: z.object({}),
|
|
158
|
+
execute: async () => {
|
|
159
|
+
toolsLogger.log(`Listing opencode sessions`);
|
|
160
|
+
const sessions = await getClient().session.list();
|
|
161
|
+
if (!sessions.data) {
|
|
162
|
+
return { success: false, error: 'No sessions found' };
|
|
163
|
+
}
|
|
164
|
+
const sortedSessions = [...sessions.data]
|
|
165
|
+
.sort((a, b) => {
|
|
166
|
+
return b.time.updated - a.time.updated;
|
|
167
|
+
})
|
|
168
|
+
.slice(0, 20);
|
|
169
|
+
const sessionList = sortedSessions.map(async (session) => {
|
|
170
|
+
const finishedAt = session.time.updated;
|
|
171
|
+
const status = await (async () => {
|
|
172
|
+
if (session.revert)
|
|
173
|
+
return 'error';
|
|
174
|
+
const messagesResponse = await getClient().session.messages({
|
|
175
|
+
path: { id: session.id },
|
|
176
|
+
});
|
|
177
|
+
const messages = messagesResponse.data || [];
|
|
178
|
+
const lastMessage = messages[messages.length - 1];
|
|
179
|
+
if (lastMessage?.info.role === 'assistant' && !lastMessage.info.time.completed) {
|
|
180
|
+
return 'in_progress';
|
|
181
|
+
}
|
|
182
|
+
return 'finished';
|
|
183
|
+
})();
|
|
184
|
+
return {
|
|
185
|
+
id: session.id,
|
|
186
|
+
folder: session.directory,
|
|
187
|
+
status,
|
|
188
|
+
finishedAt: formatDistanceToNow(new Date(finishedAt)),
|
|
189
|
+
title: session.title,
|
|
190
|
+
prompt: session.title,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const resolvedList = await Promise.all(sessionList);
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
sessions: resolvedList,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
searchFiles: tool({
|
|
201
|
+
description: 'Search for files in a folder',
|
|
202
|
+
inputSchema: z.object({
|
|
203
|
+
folder: z
|
|
204
|
+
.string()
|
|
205
|
+
.optional()
|
|
206
|
+
.describe('The folder path to search in, optional. only use if user specifically asks for it'),
|
|
207
|
+
query: z.string().describe('The search query for files'),
|
|
208
|
+
}),
|
|
209
|
+
execute: async ({ folder, query }) => {
|
|
210
|
+
const results = await getClient().find.files({
|
|
211
|
+
query: {
|
|
212
|
+
query,
|
|
213
|
+
directory: folder,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
success: true,
|
|
218
|
+
files: results.data || [],
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
readSessionMessages: tool({
|
|
223
|
+
description: 'Read messages from a chat session',
|
|
224
|
+
inputSchema: z.object({
|
|
225
|
+
sessionId: z.string().describe('The session ID to read messages from'),
|
|
226
|
+
lastAssistantOnly: z.boolean().optional().describe('Only read the last assistant message'),
|
|
227
|
+
}),
|
|
228
|
+
execute: async ({ sessionId, lastAssistantOnly = false }) => {
|
|
229
|
+
if (lastAssistantOnly) {
|
|
230
|
+
const messages = await getClient().session.messages({
|
|
231
|
+
path: { id: sessionId },
|
|
232
|
+
});
|
|
233
|
+
if (!messages.data) {
|
|
234
|
+
return { success: false, error: 'No messages found' };
|
|
235
|
+
}
|
|
236
|
+
const assistantMessages = messages.data.filter((m) => m.info.role === 'assistant');
|
|
237
|
+
if (assistantMessages.length === 0) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: 'No assistant messages found',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const lastMessage = assistantMessages[assistantMessages.length - 1];
|
|
244
|
+
const status = 'completed' in lastMessage.info.time && lastMessage.info.time.completed
|
|
245
|
+
? 'completed'
|
|
246
|
+
: 'in_progress';
|
|
247
|
+
const markdownResult = await markdownRenderer.generate({
|
|
248
|
+
sessionID: sessionId,
|
|
249
|
+
lastAssistantOnly: true,
|
|
250
|
+
});
|
|
251
|
+
if (markdownResult instanceof Error) {
|
|
252
|
+
throw new Error(markdownResult.message);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
markdown: markdownResult,
|
|
257
|
+
status,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const markdownResult = await markdownRenderer.generate({
|
|
262
|
+
sessionID: sessionId,
|
|
263
|
+
});
|
|
264
|
+
if (markdownResult instanceof Error) {
|
|
265
|
+
throw new Error(markdownResult.message);
|
|
266
|
+
}
|
|
267
|
+
const messages = await getClient().session.messages({
|
|
268
|
+
path: { id: sessionId },
|
|
269
|
+
});
|
|
270
|
+
const lastMessage = messages.data?.[messages.data.length - 1];
|
|
271
|
+
const status = lastMessage?.info.role === 'assistant' &&
|
|
272
|
+
lastMessage?.info.time &&
|
|
273
|
+
'completed' in lastMessage.info.time &&
|
|
274
|
+
!lastMessage.info.time.completed
|
|
275
|
+
? 'in_progress'
|
|
276
|
+
: 'completed';
|
|
277
|
+
return {
|
|
278
|
+
success: true,
|
|
279
|
+
markdown: markdownResult,
|
|
280
|
+
status,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
abortChat: tool({
|
|
286
|
+
description: 'Abort/stop an in-progress chat session',
|
|
287
|
+
inputSchema: z.object({
|
|
288
|
+
sessionId: z.string().describe('The session ID to abort'),
|
|
289
|
+
}),
|
|
290
|
+
execute: async ({ sessionId }) => {
|
|
291
|
+
try {
|
|
292
|
+
toolsLogger.log(`[ABORT] reason=voice-tool sessionId=${sessionId} - user requested abort via voice assistant tool`);
|
|
293
|
+
const result = await getClient().session.abort({
|
|
294
|
+
path: { id: sessionId },
|
|
295
|
+
});
|
|
296
|
+
if (!result.data) {
|
|
297
|
+
return {
|
|
298
|
+
success: false,
|
|
299
|
+
error: 'Failed to abort session',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
success: true,
|
|
304
|
+
sessionId,
|
|
305
|
+
message: 'Session aborted successfully',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
getModels: tool({
|
|
317
|
+
description: 'Get all available AI models from all providers',
|
|
318
|
+
inputSchema: z.object({}),
|
|
319
|
+
execute: async () => {
|
|
320
|
+
try {
|
|
321
|
+
const providersResponse = await getClient().config.providers({});
|
|
322
|
+
const providers = providersResponse.data?.providers || [];
|
|
323
|
+
const models = [];
|
|
324
|
+
providers.forEach((provider) => {
|
|
325
|
+
if (provider.models && typeof provider.models === 'object') {
|
|
326
|
+
Object.entries(provider.models).forEach(([modelId, model]) => {
|
|
327
|
+
models.push({
|
|
328
|
+
providerId: provider.id,
|
|
329
|
+
modelId: modelId,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
models,
|
|
337
|
+
totalCount: models.length,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return {
|
|
342
|
+
success: false,
|
|
343
|
+
error: error instanceof Error ? error.message : 'Failed to fetch models',
|
|
344
|
+
models: [],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
tools,
|
|
352
|
+
providers,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Unnest code blocks from list items for Discord.
|
|
2
|
+
// Discord doesn't render code blocks inside lists, so this hoists them
|
|
3
|
+
// to root level while preserving list structure.
|
|
4
|
+
import { Lexer } from 'marked';
|
|
5
|
+
export function unnestCodeBlocksFromLists(markdown) {
|
|
6
|
+
const lexer = new Lexer();
|
|
7
|
+
const tokens = lexer.lex(markdown);
|
|
8
|
+
const result = [];
|
|
9
|
+
for (const token of tokens) {
|
|
10
|
+
if (token.type === 'list') {
|
|
11
|
+
const segments = processListToken(token);
|
|
12
|
+
result.push(renderSegments(segments));
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
result.push(token.raw);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result.join('');
|
|
19
|
+
}
|
|
20
|
+
function processListToken(list) {
|
|
21
|
+
const segments = [];
|
|
22
|
+
const start = typeof list.start === 'number' ? list.start : parseInt(list.start, 10) || 1;
|
|
23
|
+
const prefix = list.ordered ? (i) => `${start + i}. ` : () => '- ';
|
|
24
|
+
for (let i = 0; i < list.items.length; i++) {
|
|
25
|
+
const item = list.items[i];
|
|
26
|
+
const itemSegments = processListItem(item, prefix(i));
|
|
27
|
+
segments.push(...itemSegments);
|
|
28
|
+
}
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
function processListItem(item, prefix) {
|
|
32
|
+
const segments = [];
|
|
33
|
+
let currentText = [];
|
|
34
|
+
// Track if we've seen a code block - text after code uses continuation prefix
|
|
35
|
+
let seenCodeBlock = false;
|
|
36
|
+
const flushText = () => {
|
|
37
|
+
const text = currentText.join('').trim();
|
|
38
|
+
if (text) {
|
|
39
|
+
// After a code block, use '-' as continuation prefix to avoid repeating numbers
|
|
40
|
+
const effectivePrefix = seenCodeBlock ? '- ' : prefix;
|
|
41
|
+
segments.push({ type: 'list-item', prefix: effectivePrefix, content: text });
|
|
42
|
+
}
|
|
43
|
+
currentText = [];
|
|
44
|
+
};
|
|
45
|
+
for (const token of item.tokens) {
|
|
46
|
+
if (token.type === 'code') {
|
|
47
|
+
flushText();
|
|
48
|
+
const codeToken = token;
|
|
49
|
+
const lang = codeToken.lang || '';
|
|
50
|
+
segments.push({
|
|
51
|
+
type: 'code',
|
|
52
|
+
content: '```' + lang + '\n' + codeToken.text + '\n```\n',
|
|
53
|
+
});
|
|
54
|
+
seenCodeBlock = true;
|
|
55
|
+
}
|
|
56
|
+
else if (token.type === 'list') {
|
|
57
|
+
flushText();
|
|
58
|
+
// Recursively process nested list - segments bubble up
|
|
59
|
+
const nestedSegments = processListToken(token);
|
|
60
|
+
segments.push(...nestedSegments);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
currentText.push(extractText(token));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
flushText();
|
|
67
|
+
// If no segments were created (empty item), return empty
|
|
68
|
+
if (segments.length === 0) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
// If item had no code blocks (all segments are list-items from this level),
|
|
72
|
+
// return original raw to preserve formatting
|
|
73
|
+
const hasCode = segments.some((s) => s.type === 'code');
|
|
74
|
+
if (!hasCode) {
|
|
75
|
+
return [{ type: 'list-item', prefix: '', content: item.raw }];
|
|
76
|
+
}
|
|
77
|
+
return segments;
|
|
78
|
+
}
|
|
79
|
+
function extractText(token) {
|
|
80
|
+
if (token.type === 'text') {
|
|
81
|
+
return token.text;
|
|
82
|
+
}
|
|
83
|
+
if (token.type === 'space') {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
if ('raw' in token) {
|
|
87
|
+
return token.raw;
|
|
88
|
+
}
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
function renderSegments(segments) {
|
|
92
|
+
const result = [];
|
|
93
|
+
for (let i = 0; i < segments.length; i++) {
|
|
94
|
+
const segment = segments[i];
|
|
95
|
+
const prev = segments[i - 1];
|
|
96
|
+
if (segment.type === 'code') {
|
|
97
|
+
// Add newline before code if previous was a list item
|
|
98
|
+
if (prev && prev.type === 'list-item') {
|
|
99
|
+
result.push('\n');
|
|
100
|
+
}
|
|
101
|
+
result.push(segment.content);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// list-item
|
|
105
|
+
if (segment.prefix) {
|
|
106
|
+
result.push(segment.prefix + segment.content + '\n');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Raw content (no prefix means it's original raw)
|
|
110
|
+
// Ensure raw ends with newline for proper separation from next segment
|
|
111
|
+
const raw = segment.content.trimEnd();
|
|
112
|
+
result.push(raw + '\n');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result.join('').trimEnd();
|
|
117
|
+
}
|