kimaki 0.4.37 → 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.
Files changed (53) hide show
  1. package/dist/channel-management.js +6 -2
  2. package/dist/cli.js +41 -15
  3. package/dist/commands/abort.js +15 -6
  4. package/dist/commands/add-project.js +9 -0
  5. package/dist/commands/agent.js +114 -20
  6. package/dist/commands/fork.js +13 -2
  7. package/dist/commands/model.js +12 -0
  8. package/dist/commands/remove-project.js +26 -16
  9. package/dist/commands/resume.js +9 -0
  10. package/dist/commands/session.js +13 -0
  11. package/dist/commands/share.js +10 -1
  12. package/dist/commands/undo-redo.js +13 -4
  13. package/dist/database.js +24 -5
  14. package/dist/discord-bot.js +38 -31
  15. package/dist/errors.js +110 -0
  16. package/dist/genai-worker.js +18 -16
  17. package/dist/interaction-handler.js +6 -1
  18. package/dist/markdown.js +96 -85
  19. package/dist/markdown.test.js +10 -3
  20. package/dist/message-formatting.js +50 -37
  21. package/dist/opencode.js +43 -46
  22. package/dist/session-handler.js +136 -8
  23. package/dist/system-message.js +2 -0
  24. package/dist/tools.js +18 -8
  25. package/dist/voice-handler.js +48 -25
  26. package/dist/voice.js +159 -131
  27. package/package.json +2 -1
  28. package/src/channel-management.ts +6 -2
  29. package/src/cli.ts +67 -19
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +160 -25
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +13 -0
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/database.ts +26 -4
  41. package/src/discord-bot.ts +42 -34
  42. package/src/errors.ts +208 -0
  43. package/src/genai-worker.ts +20 -17
  44. package/src/interaction-handler.ts +7 -1
  45. package/src/markdown.test.ts +13 -3
  46. package/src/markdown.ts +111 -95
  47. package/src/message-formatting.ts +55 -38
  48. package/src/opencode.ts +52 -49
  49. package/src/session-handler.ts +164 -11
  50. package/src/system-message.ts +2 -0
  51. package/src/tools.ts +18 -8
  52. package/src/voice-handler.ts +48 -23
  53. package/src/voice.ts +195 -148
@@ -4,6 +4,7 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
+ import * as errore from 'errore';
7
8
  const logger = createLogger('UNDO-REDO');
8
9
  export async function handleUndoCommand({ command }) {
9
10
  const channel = command.channel;
@@ -50,9 +51,13 @@ export async function handleUndoCommand({ command }) {
50
51
  return;
51
52
  }
52
53
  const sessionId = row.session_id;
54
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
+ const getClient = await initializeOpencodeForDirectory(directory);
56
+ if (errore.isError(getClient)) {
57
+ await command.editReply(`Failed to undo: ${getClient.message}`);
58
+ return;
59
+ }
53
60
  try {
54
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
- const getClient = await initializeOpencodeForDirectory(directory);
56
61
  // Fetch messages to find the last assistant message
57
62
  const messagesResponse = await getClient().session.messages({
58
63
  path: { id: sessionId },
@@ -133,9 +138,13 @@ export async function handleRedoCommand({ command }) {
133
138
  return;
134
139
  }
135
140
  const sessionId = row.session_id;
141
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
142
+ const getClient = await initializeOpencodeForDirectory(directory);
143
+ if (errore.isError(getClient)) {
144
+ await command.editReply(`Failed to redo: ${getClient.message}`);
145
+ return;
146
+ }
136
147
  try {
137
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
138
- const getClient = await initializeOpencodeForDirectory(directory);
139
148
  // Check if session has reverted state
140
149
  const sessionResponse = await getClient().session.get({
141
150
  path: { id: sessionId },
package/dist/database.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import Database from 'better-sqlite3';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import * as errore from 'errore';
7
8
  import { createLogger } from './logger.js';
8
9
  import { getDataDir } from './config.js';
9
10
  const dbLogger = createLogger('DB');
@@ -11,11 +12,14 @@ let db = null;
11
12
  export function getDatabase() {
12
13
  if (!db) {
13
14
  const dataDir = getDataDir();
14
- try {
15
- fs.mkdirSync(dataDir, { recursive: true });
16
- }
17
- catch (error) {
18
- dbLogger.error(`Failed to create data directory ${dataDir}:`, error);
15
+ const mkdirError = errore.tryFn({
16
+ try: () => {
17
+ fs.mkdirSync(dataDir, { recursive: true });
18
+ },
19
+ catch: (e) => e,
20
+ });
21
+ if (errore.isError(mkdirError)) {
22
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message);
19
23
  }
20
24
  const dbPath = path.join(dataDir, 'discord-sessions.db');
21
25
  dbLogger.log(`Opening database at: ${dbPath}`);
@@ -57,6 +61,13 @@ export function getDatabase() {
57
61
  catch {
58
62
  // Column already exists, ignore
59
63
  }
64
+ // Table for threads that should auto-start a session (created by CLI without --notify-only)
65
+ db.exec(`
66
+ CREATE TABLE IF NOT EXISTS pending_auto_start (
67
+ thread_id TEXT PRIMARY KEY,
68
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
69
+ )
70
+ `);
60
71
  db.exec(`
61
72
  CREATE TABLE IF NOT EXISTS bot_api_keys (
62
73
  app_id TEXT PRIMARY KEY,
@@ -147,6 +158,14 @@ export function setSessionModel(sessionId, modelId) {
147
158
  const db = getDatabase();
148
159
  db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
149
160
  }
161
+ /**
162
+ * Clear the model preference for a session.
163
+ * Used when switching agents so the agent's model takes effect.
164
+ */
165
+ export function clearSessionModel(sessionId) {
166
+ const db = getDatabase();
167
+ db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId);
168
+ }
150
169
  /**
151
170
  * Get the agent preference for a channel.
152
171
  */
@@ -18,6 +18,7 @@ export { getOpencodeSystemMessage } from './system-message.js';
18
18
  export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
19
19
  import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
20
20
  import fs from 'node:fs';
21
+ import * as errore from 'errore';
21
22
  import { extractTagsArrays } from './xml.js';
22
23
  import { createLogger } from './logger.js';
23
24
  import { setGlobalDispatcher, Agent } from 'undici';
@@ -89,11 +90,12 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
89
90
  }
90
91
  if (message.partial) {
91
92
  discordLogger.log(`Fetching partial message ${message.id}`);
92
- try {
93
- await message.fetch();
94
- }
95
- catch (error) {
96
- discordLogger.log(`Failed to fetch partial message ${message.id}:`, error);
93
+ const fetched = await errore.tryAsync({
94
+ try: () => message.fetch(),
95
+ catch: (e) => e,
96
+ });
97
+ if (errore.isError(fetched)) {
98
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
97
99
  return;
98
100
  }
99
101
  }
@@ -152,17 +154,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
152
154
  discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
153
155
  return;
154
156
  }
155
- // Include starter message (notification) as context for the session
157
+ // Include starter message as context for the session
156
158
  let prompt = message.content;
157
159
  const starterMessage = await thread.fetchStarterMessage().catch(() => null);
158
- if (starterMessage?.content) {
159
- // Strip notification prefix if present
160
- const notificationContent = starterMessage.content
161
- .replace(/^📢 \*\*Notification\*\*\n?/, '')
162
- .trim();
163
- if (notificationContent) {
164
- prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
165
- }
160
+ if (starterMessage?.content && starterMessage.content !== message.content) {
161
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
166
162
  }
167
163
  await handleOpencodeSession({
168
164
  prompt,
@@ -179,28 +175,39 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
179
175
  if (projectDirectory) {
180
176
  try {
181
177
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
178
+ if (errore.isError(getClient)) {
179
+ voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message);
180
+ throw new Error(getClient.message);
181
+ }
182
182
  const client = getClient();
183
183
  // get current session context (without system prompt, it would be duplicated)
184
184
  if (row.session_id) {
185
- currentSessionContext = await getCompactSessionContext({
185
+ const result = await getCompactSessionContext({
186
186
  client,
187
187
  sessionId: row.session_id,
188
188
  includeSystemPrompt: false,
189
189
  maxMessages: 15,
190
190
  });
191
+ if (errore.isOk(result)) {
192
+ currentSessionContext = result;
193
+ }
191
194
  }
192
195
  // get last session context (with system prompt for project context)
193
- const lastSessionId = await getLastSessionId({
196
+ const lastSessionResult = await getLastSessionId({
194
197
  client,
195
198
  excludeSessionId: row.session_id,
196
199
  });
200
+ const lastSessionId = errore.unwrapOr(lastSessionResult, null);
197
201
  if (lastSessionId) {
198
- lastSessionContext = await getCompactSessionContext({
202
+ const result = await getCompactSessionContext({
199
203
  client,
200
204
  sessionId: lastSessionId,
201
205
  includeSystemPrompt: true,
202
206
  maxMessages: 10,
203
207
  });
208
+ if (errore.isOk(result)) {
209
+ lastSessionContext = result;
210
+ }
204
211
  }
205
212
  }
206
213
  catch (e) {
@@ -316,35 +323,35 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
316
323
  }
317
324
  }
318
325
  });
319
- // Magic prefix used by `kimaki send` CLI command to initiate sessions
320
- const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
321
- // Handle bot-initiated threads created by `kimaki send`
326
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
322
327
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
323
328
  try {
324
329
  if (!newlyCreated) {
325
330
  return;
326
331
  }
332
+ // Check if this thread is marked for auto-start in the database
333
+ const db = getDatabase();
334
+ const pendingRow = db
335
+ .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
336
+ .get(thread.id);
337
+ if (!pendingRow) {
338
+ return; // Not a CLI-initiated auto-start thread
339
+ }
340
+ // Remove from pending table
341
+ db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id);
342
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
327
343
  // Only handle threads in text channels
328
344
  const parent = thread.parent;
329
345
  if (!parent || parent.type !== ChannelType.GuildText) {
330
346
  return;
331
347
  }
332
- // Get the starter message to check for magic prefix
348
+ // Get the starter message for the prompt
333
349
  const starterMessage = await thread.fetchStarterMessage().catch(() => null);
334
350
  if (!starterMessage) {
335
351
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
336
352
  return;
337
353
  }
338
- // Only handle messages from this bot with the magic prefix
339
- if (starterMessage.author.id !== discordClient.user?.id) {
340
- return;
341
- }
342
- if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
343
- return;
344
- }
345
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
346
- // Extract the prompt (everything after the prefix)
347
- const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
354
+ const prompt = starterMessage.content.trim();
348
355
  if (!prompt) {
349
356
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
350
357
  return;
package/dist/errors.js ADDED
@@ -0,0 +1,110 @@
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
+ import * as errore from 'errore';
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // INFRASTRUCTURE ERRORS - Server, filesystem, external services
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ export class DirectoryNotAccessibleError extends errore.TaggedError('DirectoryNotAccessibleError')() {
9
+ constructor(args) {
10
+ super({ ...args, message: `Directory does not exist or is not accessible: ${args.directory}` });
11
+ }
12
+ }
13
+ export class ServerStartError extends errore.TaggedError('ServerStartError')() {
14
+ constructor(args) {
15
+ super({ ...args, message: `Server failed to start on port ${args.port}: ${args.reason}` });
16
+ }
17
+ }
18
+ export class ServerNotFoundError extends errore.TaggedError('ServerNotFoundError')() {
19
+ constructor(args) {
20
+ super({ ...args, message: `OpenCode server not found for directory: ${args.directory}` });
21
+ }
22
+ }
23
+ export class ServerNotReadyError extends errore.TaggedError('ServerNotReadyError')() {
24
+ constructor(args) {
25
+ super({
26
+ ...args,
27
+ message: `OpenCode server for directory "${args.directory}" is in an error state (no client available)`,
28
+ });
29
+ }
30
+ }
31
+ export class ApiKeyMissingError extends errore.TaggedError('ApiKeyMissingError')() {
32
+ constructor(args) {
33
+ super({ ...args, message: `${args.service} API key is required` });
34
+ }
35
+ }
36
+ // ═══════════════════════════════════════════════════════════════════════════
37
+ // DOMAIN ERRORS - Sessions, messages, transcription
38
+ // ═══════════════════════════════════════════════════════════════════════════
39
+ export class SessionNotFoundError extends errore.TaggedError('SessionNotFoundError')() {
40
+ constructor(args) {
41
+ super({ ...args, message: `Session ${args.sessionId} not found` });
42
+ }
43
+ }
44
+ export class SessionCreateError extends errore.TaggedError('SessionCreateError')() {
45
+ }
46
+ export class MessagesNotFoundError extends errore.TaggedError('MessagesNotFoundError')() {
47
+ constructor(args) {
48
+ super({ ...args, message: `No messages found for session ${args.sessionId}` });
49
+ }
50
+ }
51
+ export class TranscriptionError extends errore.TaggedError('TranscriptionError')() {
52
+ constructor(args) {
53
+ super({ ...args, message: `Transcription failed: ${args.reason}` });
54
+ }
55
+ }
56
+ export class GrepSearchError extends errore.TaggedError('GrepSearchError')() {
57
+ constructor(args) {
58
+ super({ ...args, message: `Grep search failed for pattern: ${args.pattern}` });
59
+ }
60
+ }
61
+ export class GlobSearchError extends errore.TaggedError('GlobSearchError')() {
62
+ constructor(args) {
63
+ super({ ...args, message: `Glob search failed for pattern: ${args.pattern}` });
64
+ }
65
+ }
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+ // VALIDATION ERRORS - Input validation, format checks
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ export class InvalidAudioFormatError extends errore.TaggedError('InvalidAudioFormatError')() {
70
+ constructor() {
71
+ super({ message: 'Invalid audio format' });
72
+ }
73
+ }
74
+ export class EmptyTranscriptionError extends errore.TaggedError('EmptyTranscriptionError')() {
75
+ constructor() {
76
+ super({ message: 'Model returned empty transcription' });
77
+ }
78
+ }
79
+ export class NoResponseContentError extends errore.TaggedError('NoResponseContentError')() {
80
+ constructor() {
81
+ super({ message: 'No response content from model' });
82
+ }
83
+ }
84
+ export class NoToolResponseError extends errore.TaggedError('NoToolResponseError')() {
85
+ constructor() {
86
+ super({ message: 'No valid tool responses' });
87
+ }
88
+ }
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // NETWORK ERRORS - Fetch and HTTP
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+ export class FetchError extends errore.TaggedError('FetchError')() {
93
+ constructor(args) {
94
+ const causeMsg = args.cause instanceof Error ? args.cause.message : String(args.cause);
95
+ super({ ...args, message: `Fetch failed for ${args.url}: ${causeMsg}` });
96
+ }
97
+ }
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+ // API ERRORS - External service responses
100
+ // ═══════════════════════════════════════════════════════════════════════════
101
+ export class DiscordApiError extends errore.TaggedError('DiscordApiError')() {
102
+ constructor(args) {
103
+ super({ ...args, message: `Discord API error: ${args.status}${args.body ? ` - ${args.body}` : ''}` });
104
+ }
105
+ }
106
+ export class OpenCodeApiError extends errore.TaggedError('OpenCodeApiError')() {
107
+ constructor(args) {
108
+ super({ ...args, message: `OpenCode API error (${args.status})${args.body ? `: ${args.body}` : ''}` });
109
+ }
110
+ }
@@ -3,12 +3,13 @@
3
3
  // Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
4
4
  import { parentPort, threadId } from 'node:worker_threads';
5
5
  import { createWriteStream } from 'node:fs';
6
- import { mkdir } from 'node:fs/promises';
7
6
  import path from 'node:path';
7
+ import * as errore from 'errore';
8
8
  import { Resampler } from '@purinton/resampler';
9
9
  import * as prism from 'prism-media';
10
10
  import { startGenAiSession } from './genai.js';
11
11
  import { getTools } from './tools.js';
12
+ import { mkdir } from 'node:fs/promises';
12
13
  import { createLogger } from './logger.js';
13
14
  if (!parentPort) {
14
15
  throw new Error('This module must be run as a worker thread');
@@ -98,23 +99,24 @@ async function createAssistantAudioLogStream(guildId, channelId) {
98
99
  return null;
99
100
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
100
101
  const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId);
101
- try {
102
- await mkdir(audioDir, { recursive: true });
103
- // Create stream for assistant audio (24kHz mono s16le PCM)
104
- const outputFileName = `assistant_${timestamp}.24.pcm`;
105
- const outputFilePath = path.join(audioDir, outputFileName);
106
- const outputAudioStream = createWriteStream(outputFilePath);
107
- // Add error handler to prevent crashes
108
- outputAudioStream.on('error', (error) => {
109
- workerLogger.error(`Assistant audio log stream error:`, error);
110
- });
111
- workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
112
- return outputAudioStream;
113
- }
114
- catch (error) {
115
- workerLogger.error(`Failed to create audio log directory:`, error);
102
+ const mkdirError = await errore.tryAsync({
103
+ try: () => mkdir(audioDir, { recursive: true }),
104
+ catch: (e) => e,
105
+ });
106
+ if (errore.isError(mkdirError)) {
107
+ workerLogger.error(`Failed to create audio log directory:`, mkdirError.message);
116
108
  return null;
117
109
  }
110
+ // Create stream for assistant audio (24kHz mono s16le PCM)
111
+ const outputFileName = `assistant_${timestamp}.24.pcm`;
112
+ const outputFilePath = path.join(audioDir, outputFileName);
113
+ const outputAudioStream = createWriteStream(outputFilePath);
114
+ // Add error handler to prevent crashes
115
+ outputAudioStream.on('error', (error) => {
116
+ workerLogger.error(`Assistant audio log stream error:`, error);
117
+ });
118
+ workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
119
+ return outputAudioStream;
118
120
  }
119
121
  // Handle encoded Opus packets
120
122
  opusEncoder.on('data', (packet) => {
@@ -12,7 +12,7 @@ import { handleAbortCommand } from './commands/abort.js';
12
12
  import { handleShareCommand } from './commands/share.js';
13
13
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
14
14
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, } from './commands/model.js';
15
- import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
+ import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js';
16
16
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
17
17
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
18
18
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
@@ -94,6 +94,11 @@ export function registerInteractionHandler({ discordClient, appId, }) {
94
94
  await handleRedoCommand({ command: interaction, appId });
95
95
  return;
96
96
  }
97
+ // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
98
+ if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
99
+ await handleQuickAgentCommand({ command: interaction, appId });
100
+ return;
101
+ }
97
102
  // Handle user-defined commands (ending with -cmd suffix)
98
103
  if (interaction.commandName.endsWith('-cmd')) {
99
104
  await handleUserCommand({ command: interaction, appId });