kimaki 0.4.38 → 0.4.39
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/cli.js +9 -3
- package/dist/commands/abort.js +15 -6
- package/dist/commands/add-project.js +9 -0
- package/dist/commands/agent.js +13 -1
- package/dist/commands/fork.js +13 -2
- package/dist/commands/model.js +12 -0
- package/dist/commands/remove-project.js +26 -16
- package/dist/commands/resume.js +9 -0
- package/dist/commands/session.js +13 -0
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/database.js +9 -5
- package/dist/discord-bot.js +21 -8
- package/dist/errors.js +110 -0
- package/dist/genai-worker.js +18 -16
- package/dist/markdown.js +96 -85
- package/dist/markdown.test.js +10 -3
- package/dist/message-formatting.js +50 -37
- package/dist/opencode.js +43 -46
- package/dist/session-handler.js +100 -2
- package/dist/system-message.js +2 -0
- package/dist/tools.js +18 -8
- package/dist/voice-handler.js +48 -25
- package/dist/voice.js +159 -131
- package/package.json +2 -1
- package/src/cli.ts +12 -3
- package/src/commands/abort.ts +17 -7
- package/src/commands/add-project.ts +9 -0
- package/src/commands/agent.ts +13 -1
- package/src/commands/fork.ts +18 -7
- package/src/commands/model.ts +12 -0
- package/src/commands/remove-project.ts +28 -16
- package/src/commands/resume.ts +9 -0
- package/src/commands/session.ts +13 -0
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/database.ts +9 -4
- package/src/discord-bot.ts +21 -7
- package/src/errors.ts +208 -0
- package/src/genai-worker.ts +20 -17
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +111 -95
- package/src/message-formatting.ts +55 -38
- package/src/opencode.ts +52 -49
- package/src/session-handler.ts +118 -3
- package/src/system-message.ts +2 -0
- package/src/tools.ts +18 -8
- package/src/voice-handler.ts +48 -23
- package/src/voice.ts +195 -148
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /remove-project command - Remove Discord channels for a project.
|
|
2
2
|
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import * as errore from 'errore'
|
|
4
5
|
import type { CommandContext, AutocompleteContext } from './types.js'
|
|
5
6
|
import { getDatabase } from '../database.js'
|
|
6
7
|
import { createLogger } from '../logger.js'
|
|
@@ -36,19 +37,27 @@ export async function handleRemoveProjectCommand({ command, appId }: CommandCont
|
|
|
36
37
|
const failedChannels: string[] = []
|
|
37
38
|
|
|
38
39
|
for (const { channel_id, channel_type } of channels) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const channel = await errore.tryAsync({
|
|
41
|
+
try: () => guild.channels.fetch(channel_id),
|
|
42
|
+
catch: (e) => e as Error,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (errore.isError(channel)) {
|
|
46
|
+
logger.error(`Failed to fetch channel ${channel_id}:`, channel)
|
|
47
|
+
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (channel) {
|
|
52
|
+
try {
|
|
43
53
|
await channel.delete(`Removed by /remove-project command`)
|
|
44
54
|
deletedChannels.push(`${channel_type}: ${channel_id}`)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.error(`Failed to delete channel ${channel_id}:`, error)
|
|
57
|
+
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
48
58
|
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
59
|
+
} else {
|
|
60
|
+
deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -103,13 +112,16 @@ export async function handleRemoveProjectAutocomplete({
|
|
|
103
112
|
const projectsInGuild: { directory: string; channelId: string }[] = []
|
|
104
113
|
|
|
105
114
|
for (const { directory, channel_id } of allChannels) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
} catch {
|
|
115
|
+
const channel = await errore.tryAsync({
|
|
116
|
+
try: () => guild.channels.fetch(channel_id),
|
|
117
|
+
catch: (e) => e as Error,
|
|
118
|
+
})
|
|
119
|
+
if (errore.isError(channel)) {
|
|
112
120
|
// Channel not in this guild, skip
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (channel) {
|
|
124
|
+
projectsInGuild.push({ directory, channelId: channel_id })
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
127
|
|
package/src/commands/resume.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
|
|
|
14
14
|
import { extractTagsArrays } from '../xml.js'
|
|
15
15
|
import { collectLastAssistantParts } from '../message-formatting.js'
|
|
16
16
|
import { createLogger } from '../logger.js'
|
|
17
|
+
import * as errore from 'errore'
|
|
17
18
|
|
|
18
19
|
const logger = createLogger('RESUME')
|
|
19
20
|
|
|
@@ -60,6 +61,10 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
|
|
|
60
61
|
|
|
61
62
|
try {
|
|
62
63
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
64
|
+
if (errore.isError(getClient)) {
|
|
65
|
+
await command.editReply(getClient.message)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
63
68
|
|
|
64
69
|
const sessionResponse = await getClient().session.get({
|
|
65
70
|
path: { id: sessionId },
|
|
@@ -168,6 +173,10 @@ export async function handleResumeAutocomplete({
|
|
|
168
173
|
|
|
169
174
|
try {
|
|
170
175
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
176
|
+
if (errore.isError(getClient)) {
|
|
177
|
+
await interaction.respond([])
|
|
178
|
+
return
|
|
179
|
+
}
|
|
171
180
|
|
|
172
181
|
const sessionsResponse = await getClient().session.list()
|
|
173
182
|
if (!sessionsResponse.data) {
|
package/src/commands/session.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
|
10
10
|
import { extractTagsArrays } from '../xml.js'
|
|
11
11
|
import { handleOpencodeSession } from '../session-handler.js'
|
|
12
12
|
import { createLogger } from '../logger.js'
|
|
13
|
+
import * as errore from 'errore'
|
|
13
14
|
|
|
14
15
|
const logger = createLogger('SESSION')
|
|
15
16
|
|
|
@@ -58,6 +59,10 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
|
|
|
58
59
|
|
|
59
60
|
try {
|
|
60
61
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
62
|
+
if (errore.isError(getClient)) {
|
|
63
|
+
await command.editReply(getClient.message)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
61
66
|
|
|
62
67
|
const files = filesString
|
|
63
68
|
.split(',')
|
|
@@ -128,6 +133,10 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
|
|
|
128
133
|
|
|
129
134
|
try {
|
|
130
135
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
136
|
+
if (errore.isError(getClient)) {
|
|
137
|
+
await interaction.respond([])
|
|
138
|
+
return
|
|
139
|
+
}
|
|
131
140
|
|
|
132
141
|
const agentsResponse = await getClient().app.agents({
|
|
133
142
|
query: { directory: projectDirectory },
|
|
@@ -207,6 +216,10 @@ export async function handleSessionAutocomplete({
|
|
|
207
216
|
|
|
208
217
|
try {
|
|
209
218
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
219
|
+
if (errore.isError(getClient)) {
|
|
220
|
+
await interaction.respond([])
|
|
221
|
+
return
|
|
222
|
+
}
|
|
210
223
|
|
|
211
224
|
const response = await getClient().find.files({
|
|
212
225
|
query: {
|
package/src/commands/share.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
8
8
|
import { createLogger } from '../logger.js'
|
|
9
|
+
import * as errore from 'errore'
|
|
9
10
|
|
|
10
11
|
const logger = createLogger('SHARE')
|
|
11
12
|
|
|
@@ -63,8 +64,17 @@ export async function handleShareCommand({ command }: CommandContext): Promise<v
|
|
|
63
64
|
|
|
64
65
|
const sessionId = row.session_id
|
|
65
66
|
|
|
67
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
68
|
+
if (errore.isError(getClient)) {
|
|
69
|
+
await command.reply({
|
|
70
|
+
content: `Failed to share session: ${getClient.message}`,
|
|
71
|
+
ephemeral: true,
|
|
72
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
73
|
+
})
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
try {
|
|
67
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
68
78
|
const response = await getClient().session.share({
|
|
69
79
|
path: { id: sessionId },
|
|
70
80
|
})
|
|
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
8
8
|
import { createLogger } from '../logger.js'
|
|
9
|
+
import * as errore from 'errore'
|
|
9
10
|
|
|
10
11
|
const logger = createLogger('UNDO-REDO')
|
|
11
12
|
|
|
@@ -63,11 +64,15 @@ export async function handleUndoCommand({ command }: CommandContext): Promise<vo
|
|
|
63
64
|
|
|
64
65
|
const sessionId = row.session_id
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
67
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
70
|
+
if (errore.isError(getClient)) {
|
|
71
|
+
await command.editReply(`Failed to undo: ${getClient.message}`)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
70
74
|
|
|
75
|
+
try {
|
|
71
76
|
// Fetch messages to find the last assistant message
|
|
72
77
|
const messagesResponse = await getClient().session.messages({
|
|
73
78
|
path: { id: sessionId },
|
|
@@ -166,11 +171,15 @@ export async function handleRedoCommand({ command }: CommandContext): Promise<vo
|
|
|
166
171
|
|
|
167
172
|
const sessionId = row.session_id
|
|
168
173
|
|
|
169
|
-
|
|
170
|
-
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
174
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
177
|
+
if (errore.isError(getClient)) {
|
|
178
|
+
await command.editReply(`Failed to redo: ${getClient.message}`)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
173
181
|
|
|
182
|
+
try {
|
|
174
183
|
// Check if session has reverted state
|
|
175
184
|
const sessionResponse = await getClient().session.get({
|
|
176
185
|
path: { id: sessionId },
|
package/src/database.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
import fs from 'node:fs'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import * as errore from 'errore'
|
|
8
9
|
import { createLogger } from './logger.js'
|
|
9
10
|
import { getDataDir } from './config.js'
|
|
10
11
|
|
|
@@ -16,10 +17,14 @@ export function getDatabase(): Database.Database {
|
|
|
16
17
|
if (!db) {
|
|
17
18
|
const dataDir = getDataDir()
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const mkdirError = errore.tryFn({
|
|
21
|
+
try: () => {
|
|
22
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
23
|
+
},
|
|
24
|
+
catch: (e) => e as Error,
|
|
25
|
+
})
|
|
26
|
+
if (errore.isError(mkdirError)) {
|
|
27
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message)
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
const dbPath = path.join(dataDir, 'discord-sessions.db')
|
package/src/discord-bot.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
type ThreadChannel,
|
|
54
54
|
} from 'discord.js'
|
|
55
55
|
import fs from 'node:fs'
|
|
56
|
+
import * as errore from 'errore'
|
|
56
57
|
import { extractTagsArrays } from './xml.js'
|
|
57
58
|
import { createLogger } from './logger.js'
|
|
58
59
|
import { setGlobalDispatcher, Agent } from 'undici'
|
|
@@ -149,10 +150,12 @@ export async function startDiscordBot({
|
|
|
149
150
|
}
|
|
150
151
|
if (message.partial) {
|
|
151
152
|
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
const fetched = await errore.tryAsync({
|
|
154
|
+
try: () => message.fetch(),
|
|
155
|
+
catch: (e) => e as Error,
|
|
156
|
+
})
|
|
157
|
+
if (errore.isError(fetched)) {
|
|
158
|
+
discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message)
|
|
156
159
|
return
|
|
157
160
|
}
|
|
158
161
|
}
|
|
@@ -256,30 +259,41 @@ export async function startDiscordBot({
|
|
|
256
259
|
if (projectDirectory) {
|
|
257
260
|
try {
|
|
258
261
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
262
|
+
if (errore.isError(getClient)) {
|
|
263
|
+
voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message)
|
|
264
|
+
throw new Error(getClient.message)
|
|
265
|
+
}
|
|
259
266
|
const client = getClient()
|
|
260
267
|
|
|
261
268
|
// get current session context (without system prompt, it would be duplicated)
|
|
262
269
|
if (row.session_id) {
|
|
263
|
-
|
|
270
|
+
const result = await getCompactSessionContext({
|
|
264
271
|
client,
|
|
265
272
|
sessionId: row.session_id,
|
|
266
273
|
includeSystemPrompt: false,
|
|
267
274
|
maxMessages: 15,
|
|
268
275
|
})
|
|
276
|
+
if (errore.isOk(result)) {
|
|
277
|
+
currentSessionContext = result
|
|
278
|
+
}
|
|
269
279
|
}
|
|
270
280
|
|
|
271
281
|
// get last session context (with system prompt for project context)
|
|
272
|
-
const
|
|
282
|
+
const lastSessionResult = await getLastSessionId({
|
|
273
283
|
client,
|
|
274
284
|
excludeSessionId: row.session_id,
|
|
275
285
|
})
|
|
286
|
+
const lastSessionId = errore.unwrapOr(lastSessionResult, null)
|
|
276
287
|
if (lastSessionId) {
|
|
277
|
-
|
|
288
|
+
const result = await getCompactSessionContext({
|
|
278
289
|
client,
|
|
279
290
|
sessionId: lastSessionId,
|
|
280
291
|
includeSystemPrompt: true,
|
|
281
292
|
maxMessages: 10,
|
|
282
293
|
})
|
|
294
|
+
if (errore.isOk(result)) {
|
|
295
|
+
lastSessionContext = result
|
|
296
|
+
}
|
|
283
297
|
}
|
|
284
298
|
} catch (e) {
|
|
285
299
|
voiceLogger.error(`Could not get session context:`, e)
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// TaggedError definitions for type-safe error handling with errore.
|
|
2
|
+
// Errors are grouped by category: infrastructure, domain, and validation.
|
|
3
|
+
// Use errore.matchError() for exhaustive error handling in command handlers.
|
|
4
|
+
|
|
5
|
+
import * as errore from 'errore'
|
|
6
|
+
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
// INFRASTRUCTURE ERRORS - Server, filesystem, external services
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
export class DirectoryNotAccessibleError extends errore.TaggedError('DirectoryNotAccessibleError')<{
|
|
12
|
+
directory: string
|
|
13
|
+
message: string
|
|
14
|
+
}>() {
|
|
15
|
+
constructor(args: { directory: string }) {
|
|
16
|
+
super({ ...args, message: `Directory does not exist or is not accessible: ${args.directory}` })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ServerStartError extends errore.TaggedError('ServerStartError')<{
|
|
21
|
+
port: number
|
|
22
|
+
reason: string
|
|
23
|
+
message: string
|
|
24
|
+
}>() {
|
|
25
|
+
constructor(args: { port: number; reason: string }) {
|
|
26
|
+
super({ ...args, message: `Server failed to start on port ${args.port}: ${args.reason}` })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ServerNotFoundError extends errore.TaggedError('ServerNotFoundError')<{
|
|
31
|
+
directory: string
|
|
32
|
+
message: string
|
|
33
|
+
}>() {
|
|
34
|
+
constructor(args: { directory: string }) {
|
|
35
|
+
super({ ...args, message: `OpenCode server not found for directory: ${args.directory}` })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ServerNotReadyError extends errore.TaggedError('ServerNotReadyError')<{
|
|
40
|
+
directory: string
|
|
41
|
+
message: string
|
|
42
|
+
}>() {
|
|
43
|
+
constructor(args: { directory: string }) {
|
|
44
|
+
super({
|
|
45
|
+
...args,
|
|
46
|
+
message: `OpenCode server for directory "${args.directory}" is in an error state (no client available)`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ApiKeyMissingError extends errore.TaggedError('ApiKeyMissingError')<{
|
|
52
|
+
service: string
|
|
53
|
+
message: string
|
|
54
|
+
}>() {
|
|
55
|
+
constructor(args: { service: string }) {
|
|
56
|
+
super({ ...args, message: `${args.service} API key is required` })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
61
|
+
// DOMAIN ERRORS - Sessions, messages, transcription
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
export class SessionNotFoundError extends errore.TaggedError('SessionNotFoundError')<{
|
|
65
|
+
sessionId: string
|
|
66
|
+
message: string
|
|
67
|
+
}>() {
|
|
68
|
+
constructor(args: { sessionId: string }) {
|
|
69
|
+
super({ ...args, message: `Session ${args.sessionId} not found` })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class SessionCreateError extends errore.TaggedError('SessionCreateError')<{
|
|
74
|
+
message: string
|
|
75
|
+
cause?: unknown
|
|
76
|
+
}>() {}
|
|
77
|
+
|
|
78
|
+
export class MessagesNotFoundError extends errore.TaggedError('MessagesNotFoundError')<{
|
|
79
|
+
sessionId: string
|
|
80
|
+
message: string
|
|
81
|
+
}>() {
|
|
82
|
+
constructor(args: { sessionId: string }) {
|
|
83
|
+
super({ ...args, message: `No messages found for session ${args.sessionId}` })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class TranscriptionError extends errore.TaggedError('TranscriptionError')<{
|
|
88
|
+
reason: string
|
|
89
|
+
message: string
|
|
90
|
+
cause?: unknown
|
|
91
|
+
}>() {
|
|
92
|
+
constructor(args: { reason: string; cause?: unknown }) {
|
|
93
|
+
super({ ...args, message: `Transcription failed: ${args.reason}` })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class GrepSearchError extends errore.TaggedError('GrepSearchError')<{
|
|
98
|
+
pattern: string
|
|
99
|
+
message: string
|
|
100
|
+
cause?: unknown
|
|
101
|
+
}>() {
|
|
102
|
+
constructor(args: { pattern: string; cause?: unknown }) {
|
|
103
|
+
super({ ...args, message: `Grep search failed for pattern: ${args.pattern}` })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class GlobSearchError extends errore.TaggedError('GlobSearchError')<{
|
|
108
|
+
pattern: string
|
|
109
|
+
message: string
|
|
110
|
+
cause?: unknown
|
|
111
|
+
}>() {
|
|
112
|
+
constructor(args: { pattern: string; cause?: unknown }) {
|
|
113
|
+
super({ ...args, message: `Glob search failed for pattern: ${args.pattern}` })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// VALIDATION ERRORS - Input validation, format checks
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
export class InvalidAudioFormatError extends errore.TaggedError('InvalidAudioFormatError')<{
|
|
122
|
+
message: string
|
|
123
|
+
}>() {
|
|
124
|
+
constructor() {
|
|
125
|
+
super({ message: 'Invalid audio format' })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class EmptyTranscriptionError extends errore.TaggedError('EmptyTranscriptionError')<{
|
|
130
|
+
message: string
|
|
131
|
+
}>() {
|
|
132
|
+
constructor() {
|
|
133
|
+
super({ message: 'Model returned empty transcription' })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class NoResponseContentError extends errore.TaggedError('NoResponseContentError')<{
|
|
138
|
+
message: string
|
|
139
|
+
}>() {
|
|
140
|
+
constructor() {
|
|
141
|
+
super({ message: 'No response content from model' })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class NoToolResponseError extends errore.TaggedError('NoToolResponseError')<{
|
|
146
|
+
message: string
|
|
147
|
+
}>() {
|
|
148
|
+
constructor() {
|
|
149
|
+
super({ message: 'No valid tool responses' })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
154
|
+
// NETWORK ERRORS - Fetch and HTTP
|
|
155
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
156
|
+
|
|
157
|
+
export class FetchError extends errore.TaggedError('FetchError')<{
|
|
158
|
+
url: string
|
|
159
|
+
message: string
|
|
160
|
+
cause?: unknown
|
|
161
|
+
}>() {
|
|
162
|
+
constructor(args: { url: string; cause?: unknown }) {
|
|
163
|
+
const causeMsg = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
164
|
+
super({ ...args, message: `Fetch failed for ${args.url}: ${causeMsg}` })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
169
|
+
// API ERRORS - External service responses
|
|
170
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
export class DiscordApiError extends errore.TaggedError('DiscordApiError')<{
|
|
173
|
+
status: number
|
|
174
|
+
message: string
|
|
175
|
+
}>() {
|
|
176
|
+
constructor(args: { status: number; body?: string }) {
|
|
177
|
+
super({ ...args, message: `Discord API error: ${args.status}${args.body ? ` - ${args.body}` : ''}` })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class OpenCodeApiError extends errore.TaggedError('OpenCodeApiError')<{
|
|
182
|
+
status: number
|
|
183
|
+
message: string
|
|
184
|
+
}>() {
|
|
185
|
+
constructor(args: { status: number; body?: string }) {
|
|
186
|
+
super({ ...args, message: `OpenCode API error (${args.status})${args.body ? `: ${args.body}` : ''}` })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
191
|
+
// UNION TYPES - For function signatures
|
|
192
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
export type TranscriptionErrors =
|
|
195
|
+
| ApiKeyMissingError
|
|
196
|
+
| InvalidAudioFormatError
|
|
197
|
+
| TranscriptionError
|
|
198
|
+
| EmptyTranscriptionError
|
|
199
|
+
| NoResponseContentError
|
|
200
|
+
| NoToolResponseError
|
|
201
|
+
|
|
202
|
+
export type OpenCodeErrors =
|
|
203
|
+
| DirectoryNotAccessibleError
|
|
204
|
+
| ServerStartError
|
|
205
|
+
| ServerNotFoundError
|
|
206
|
+
| ServerNotReadyError
|
|
207
|
+
|
|
208
|
+
export type SessionErrors = SessionNotFoundError | MessagesNotFoundError | OpenCodeApiError
|
package/src/genai-worker.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { parentPort, threadId } from 'node:worker_threads'
|
|
6
6
|
import { createWriteStream, type WriteStream } from 'node:fs'
|
|
7
|
-
import { mkdir } from 'node:fs/promises'
|
|
8
7
|
import path from 'node:path'
|
|
8
|
+
import * as errore from 'errore'
|
|
9
9
|
import { Resampler } from '@purinton/resampler'
|
|
10
10
|
import * as prism from 'prism-media'
|
|
11
11
|
import { startGenAiSession } from './genai.js'
|
|
12
12
|
import type { Session } from '@google/genai'
|
|
13
13
|
import { getTools } from './tools.js'
|
|
14
|
+
import { mkdir } from 'node:fs/promises'
|
|
14
15
|
import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
|
|
15
16
|
import { createLogger } from './logger.js'
|
|
16
17
|
|
|
@@ -127,26 +128,28 @@ async function createAssistantAudioLogStream(
|
|
|
127
128
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
128
129
|
const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId)
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const mkdirError = await errore.tryAsync({
|
|
132
|
+
try: () => mkdir(audioDir, { recursive: true }),
|
|
133
|
+
catch: (e) => e as Error,
|
|
134
|
+
})
|
|
135
|
+
if (errore.isError(mkdirError)) {
|
|
136
|
+
workerLogger.error(`Failed to create audio log directory:`, mkdirError.message)
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
// Create stream for assistant audio (24kHz mono s16le PCM)
|
|
141
|
+
const outputFileName = `assistant_${timestamp}.24.pcm`
|
|
142
|
+
const outputFilePath = path.join(audioDir, outputFileName)
|
|
143
|
+
const outputAudioStream = createWriteStream(outputFilePath)
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
// Add error handler to prevent crashes
|
|
146
|
+
outputAudioStream.on('error', (error) => {
|
|
147
|
+
workerLogger.error(`Assistant audio log stream error:`, error)
|
|
148
|
+
})
|
|
142
149
|
|
|
143
|
-
|
|
150
|
+
workerLogger.log(`Created assistant audio log: ${outputFilePath}`)
|
|
144
151
|
|
|
145
|
-
|
|
146
|
-
} catch (error) {
|
|
147
|
-
workerLogger.error(`Failed to create audio log directory:`, error)
|
|
148
|
-
return null
|
|
149
|
-
}
|
|
152
|
+
return outputAudioStream
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
// Handle encoded Opus packets
|
package/src/markdown.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { spawn, type ChildProcess } from 'child_process'
|
|
3
3
|
import { OpencodeClient } from '@opencode-ai/sdk'
|
|
4
|
+
import * as errore from 'errore'
|
|
4
5
|
import { ShareMarkdown, getCompactSessionContext } from './markdown.js'
|
|
5
6
|
|
|
6
7
|
let serverProcess: ChildProcess
|
|
@@ -121,11 +122,14 @@ test('generate markdown from first available session', async () => {
|
|
|
121
122
|
const exporter = new ShareMarkdown(client)
|
|
122
123
|
|
|
123
124
|
// Generate markdown with system info
|
|
124
|
-
const
|
|
125
|
+
const markdownResult = await exporter.generate({
|
|
125
126
|
sessionID,
|
|
126
127
|
includeSystemInfo: true,
|
|
127
128
|
})
|
|
128
129
|
|
|
130
|
+
expect(errore.isOk(markdownResult)).toBe(true)
|
|
131
|
+
const markdown = errore.unwrap(markdownResult)
|
|
132
|
+
|
|
129
133
|
console.log(`Generated markdown length: ${markdown.length} characters`)
|
|
130
134
|
|
|
131
135
|
// Basic assertions
|
|
@@ -299,13 +303,16 @@ test('generate markdown from multiple sessions', async () => {
|
|
|
299
303
|
test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
|
|
300
304
|
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
|
|
301
305
|
|
|
302
|
-
const
|
|
306
|
+
const contextResult = await getCompactSessionContext({
|
|
303
307
|
client,
|
|
304
308
|
sessionId,
|
|
305
309
|
includeSystemPrompt: true,
|
|
306
310
|
maxMessages: 15,
|
|
307
311
|
})
|
|
308
312
|
|
|
313
|
+
expect(errore.isOk(contextResult)).toBe(true)
|
|
314
|
+
const context = errore.unwrap(contextResult)
|
|
315
|
+
|
|
309
316
|
console.log(`Generated compact context length: ${context.length} characters`)
|
|
310
317
|
|
|
311
318
|
expect(context).toBeTruthy()
|
|
@@ -319,13 +326,16 @@ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format',
|
|
|
319
326
|
test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
|
|
320
327
|
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
|
|
321
328
|
|
|
322
|
-
const
|
|
329
|
+
const contextResult = await getCompactSessionContext({
|
|
323
330
|
client,
|
|
324
331
|
sessionId,
|
|
325
332
|
includeSystemPrompt: false,
|
|
326
333
|
maxMessages: 10,
|
|
327
334
|
})
|
|
328
335
|
|
|
336
|
+
expect(errore.isOk(contextResult)).toBe(true)
|
|
337
|
+
const context = errore.unwrap(contextResult)
|
|
338
|
+
|
|
329
339
|
console.log(`Generated compact context (no system) length: ${context.length} characters`)
|
|
330
340
|
|
|
331
341
|
expect(context).toBeTruthy()
|