kimaki 0.4.46 → 0.4.48

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 (85) hide show
  1. package/dist/cli.js +69 -21
  2. package/dist/commands/abort.js +4 -2
  3. package/dist/commands/add-project.js +2 -2
  4. package/dist/commands/agent.js +4 -4
  5. package/dist/commands/ask-question.js +9 -8
  6. package/dist/commands/compact.js +126 -0
  7. package/dist/commands/create-new-project.js +60 -30
  8. package/dist/commands/fork.js +3 -3
  9. package/dist/commands/merge-worktree.js +23 -10
  10. package/dist/commands/model.js +5 -5
  11. package/dist/commands/permissions.js +5 -3
  12. package/dist/commands/queue.js +2 -2
  13. package/dist/commands/remove-project.js +2 -2
  14. package/dist/commands/resume.js +2 -2
  15. package/dist/commands/session.js +6 -3
  16. package/dist/commands/share.js +2 -2
  17. package/dist/commands/undo-redo.js +2 -2
  18. package/dist/commands/user-command.js +2 -2
  19. package/dist/commands/verbosity.js +5 -5
  20. package/dist/commands/worktree-settings.js +2 -2
  21. package/dist/commands/worktree.js +18 -8
  22. package/dist/config.js +7 -0
  23. package/dist/database.js +10 -7
  24. package/dist/discord-bot.js +30 -12
  25. package/dist/discord-utils.js +2 -2
  26. package/dist/genai-worker-wrapper.js +3 -3
  27. package/dist/genai-worker.js +2 -2
  28. package/dist/genai.js +2 -2
  29. package/dist/interaction-handler.js +6 -2
  30. package/dist/logger.js +57 -9
  31. package/dist/markdown.js +2 -2
  32. package/dist/message-formatting.js +91 -6
  33. package/dist/openai-realtime.js +2 -2
  34. package/dist/opencode.js +19 -25
  35. package/dist/session-handler.js +89 -29
  36. package/dist/system-message.js +11 -9
  37. package/dist/tools.js +3 -2
  38. package/dist/utils.js +1 -0
  39. package/dist/voice-handler.js +2 -2
  40. package/dist/voice.js +2 -2
  41. package/dist/worktree-utils.js +91 -7
  42. package/dist/xml.js +2 -2
  43. package/package.json +3 -3
  44. package/src/cli.ts +108 -21
  45. package/src/commands/abort.ts +4 -2
  46. package/src/commands/add-project.ts +2 -2
  47. package/src/commands/agent.ts +4 -4
  48. package/src/commands/ask-question.ts +9 -8
  49. package/src/commands/compact.ts +148 -0
  50. package/src/commands/create-new-project.ts +87 -36
  51. package/src/commands/fork.ts +3 -3
  52. package/src/commands/merge-worktree.ts +47 -10
  53. package/src/commands/model.ts +5 -5
  54. package/src/commands/permissions.ts +6 -2
  55. package/src/commands/queue.ts +2 -2
  56. package/src/commands/remove-project.ts +2 -2
  57. package/src/commands/resume.ts +2 -2
  58. package/src/commands/session.ts +6 -3
  59. package/src/commands/share.ts +2 -2
  60. package/src/commands/undo-redo.ts +2 -2
  61. package/src/commands/user-command.ts +2 -2
  62. package/src/commands/verbosity.ts +5 -5
  63. package/src/commands/worktree-settings.ts +2 -2
  64. package/src/commands/worktree.ts +20 -7
  65. package/src/config.ts +14 -0
  66. package/src/database.ts +13 -7
  67. package/src/discord-bot.ts +45 -12
  68. package/src/discord-utils.ts +2 -2
  69. package/src/genai-worker-wrapper.ts +3 -3
  70. package/src/genai-worker.ts +2 -2
  71. package/src/genai.ts +2 -2
  72. package/src/interaction-handler.ts +7 -2
  73. package/src/logger.ts +64 -10
  74. package/src/markdown.ts +2 -2
  75. package/src/message-formatting.ts +100 -6
  76. package/src/openai-realtime.ts +2 -2
  77. package/src/opencode.ts +19 -26
  78. package/src/session-handler.ts +102 -29
  79. package/src/system-message.ts +11 -9
  80. package/src/tools.ts +3 -2
  81. package/src/utils.ts +1 -0
  82. package/src/voice-handler.ts +2 -2
  83. package/src/voice.ts +2 -2
  84. package/src/worktree-utils.ts +111 -7
  85. package/src/xml.ts +2 -2
@@ -14,12 +14,12 @@ import {
14
14
  } from '../database.js'
15
15
  import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
16
16
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
17
- import { createLogger } from '../logger.js'
18
- import { createWorktreeWithSubmodules } from '../worktree-utils.js'
17
+ import { createLogger, LogPrefix } from '../logger.js'
18
+ import { createWorktreeWithSubmodules, captureGitDiff, type CapturedDiff } from '../worktree-utils.js'
19
19
  import { WORKTREE_PREFIX } from './merge-worktree.js'
20
20
  import * as errore from 'errore'
21
21
 
22
- const logger = createLogger('WORKTREE')
22
+ const logger = createLogger(LogPrefix.WORKTREE)
23
23
 
24
24
  class WorktreeError extends Error {
25
25
  constructor(message: string, options?: { cause?: unknown }) {
@@ -91,6 +91,7 @@ function getProjectDirectoryFromChannel(
91
91
 
92
92
  /**
93
93
  * Create worktree in background and update starter message when done.
94
+ * If diff is provided, it's applied during worktree creation (before submodule init).
94
95
  */
95
96
  async function createWorktreeInBackground({
96
97
  thread,
@@ -98,19 +99,22 @@ async function createWorktreeInBackground({
98
99
  worktreeName,
99
100
  projectDirectory,
100
101
  clientV2,
102
+ diff,
101
103
  }: {
102
104
  thread: ThreadChannel
103
105
  starterMessage: Message
104
106
  worktreeName: string
105
107
  projectDirectory: string
106
108
  clientV2: ReturnType<typeof getOpencodeClientV2> & {}
109
+ diff?: CapturedDiff | null
107
110
  }): Promise<void> {
108
- // Create worktree using SDK v2 and init submodules
111
+ // Create worktree using SDK v2, apply diff, then init submodules
109
112
  logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`)
110
113
  const worktreeResult = await createWorktreeWithSubmodules({
111
114
  clientV2,
112
115
  directory: projectDirectory,
113
116
  name: worktreeName,
117
+ diff,
114
118
  })
115
119
 
116
120
  if (worktreeResult instanceof Error) {
@@ -123,10 +127,12 @@ async function createWorktreeInBackground({
123
127
 
124
128
  // Success - update database and edit starter message
125
129
  setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory })
130
+ const diffStatus = diff ? (worktreeResult.diffApplied ? '\n✅ Changes applied' : '\n⚠️ Failed to apply changes') : ''
126
131
  await starterMessage.edit(
127
132
  `🌳 **Worktree: ${worktreeName}**\n` +
128
133
  `📁 \`${worktreeResult.directory}\`\n` +
129
- `🌿 Branch: \`${worktreeResult.branch}\``
134
+ `🌿 Branch: \`${worktreeResult.branch}\`` +
135
+ diffStatus
130
136
  )
131
137
  }
132
138
 
@@ -337,6 +343,11 @@ async function handleWorktreeInThread({
337
343
  return
338
344
  }
339
345
 
346
+ // Capture git diff from project directory before creating worktree.
347
+ // This allows transferring uncommitted changes to the new worktree.
348
+ const diff = await captureGitDiff(projectDirectory)
349
+ const hasDiff = diff && (diff.staged || diff.unstaged)
350
+
340
351
  // Store pending worktree in database for this existing thread
341
352
  createPendingWorktree({
342
353
  threadId: thread.id,
@@ -345,20 +356,22 @@ async function handleWorktreeInThread({
345
356
  })
346
357
 
347
358
  // Send status message in thread
359
+ const diffNote = hasDiff ? '\n📋 Will transfer uncommitted changes' : ''
348
360
  const statusMessage = await thread.send({
349
- content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
361
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...${diffNote}`,
350
362
  flags: SILENT_MESSAGE_FLAGS,
351
363
  })
352
364
 
353
365
  await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`)
354
366
 
355
- // Create worktree in background
367
+ // Create worktree in background, passing diff to apply after creation
356
368
  createWorktreeInBackground({
357
369
  thread,
358
370
  starterMessage: statusMessage,
359
371
  worktreeName,
360
372
  projectDirectory,
361
373
  clientV2,
374
+ diff,
362
375
  }).catch((e) => {
363
376
  logger.error('[NEW-WORKTREE] Background error:', e)
364
377
  })
package/src/config.ts CHANGED
@@ -44,6 +44,20 @@ export function getProjectsDir(): string {
44
44
  return path.join(getDataDir(), 'projects')
45
45
  }
46
46
 
47
+ // Default verbosity for channels that haven't set a per-channel override.
48
+ // Set via --verbosity CLI flag at startup.
49
+ import type { VerbosityLevel } from './database.js'
50
+
51
+ let defaultVerbosity: VerbosityLevel = 'tools-and-text'
52
+
53
+ export function getDefaultVerbosity(): VerbosityLevel {
54
+ return defaultVerbosity
55
+ }
56
+
57
+ export function setDefaultVerbosity(level: VerbosityLevel): void {
58
+ defaultVerbosity = level
59
+ }
60
+
47
61
  const DEFAULT_LOCK_PORT = 29988
48
62
 
49
63
  /**
package/src/database.ts CHANGED
@@ -6,10 +6,10 @@ import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import * as errore from 'errore'
9
- import { createLogger } from './logger.js'
10
- import { getDataDir } from './config.js'
9
+ import { createLogger, LogPrefix } from './logger.js'
10
+ import { getDataDir, getDefaultVerbosity } from './config.js'
11
11
 
12
- const dbLogger = createLogger('DB')
12
+ const dbLogger = createLogger(LogPrefix.DB)
13
13
 
14
14
  let db: Database.Database | null = null
15
15
 
@@ -69,8 +69,11 @@ export function getDatabase(): Database.Database {
69
69
  // Migration: add app_id column to channel_directories for multi-bot support
70
70
  try {
71
71
  db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
72
- } catch {
73
- // Column already exists, ignore
72
+ } catch (error) {
73
+ dbLogger.debug(
74
+ 'Failed to add app_id column to channel_directories (likely exists):',
75
+ error instanceof Error ? error.message : String(error),
76
+ )
74
77
  }
75
78
 
76
79
  // Table for threads that should auto-start a session (created by CLI without --notify-only)
@@ -378,14 +381,17 @@ export function runVerbosityMigrations(database?: Database.Database): void {
378
381
 
379
382
  /**
380
383
  * Get the verbosity setting for a channel.
381
- * @returns 'tools-and-text' (default) or 'text-only'
384
+ * Falls back to the global default set via --verbosity CLI flag if no per-channel override exists.
382
385
  */
383
386
  export function getChannelVerbosity(channelId: string): VerbosityLevel {
384
387
  const db = getDatabase()
385
388
  const row = db
386
389
  .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
387
390
  .get(channelId) as { verbosity: string } | undefined
388
- return (row?.verbosity as VerbosityLevel) || 'tools-and-text'
391
+ if (row?.verbosity) {
392
+ return row.verbosity as VerbosityLevel
393
+ }
394
+ return getDefaultVerbosity()
389
395
  }
390
396
 
391
397
  /**
@@ -66,7 +66,7 @@ import {
66
66
  } from 'discord.js'
67
67
  import fs from 'node:fs'
68
68
  import * as errore from 'errore'
69
- import { createLogger } from './logger.js'
69
+ import { createLogger, LogPrefix } from './logger.js'
70
70
  import { setGlobalDispatcher, Agent } from 'undici'
71
71
 
72
72
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
@@ -74,8 +74,12 @@ import { setGlobalDispatcher, Agent } from 'undici'
74
74
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
75
75
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }))
76
76
 
77
- const discordLogger = createLogger('DISCORD')
78
- const voiceLogger = createLogger('VOICE')
77
+ const discordLogger = createLogger(LogPrefix.DISCORD)
78
+ const voiceLogger = createLogger(LogPrefix.VOICE)
79
+
80
+ function prefixWithDiscordUser({ username, prompt }: { username: string; prompt: string }): string {
81
+ return `<discord-user name="${username}" />\n${prompt}`
82
+ }
79
83
 
80
84
  type StartOptions = {
81
85
  token: string
@@ -165,6 +169,17 @@ export async function startDiscordBot({
165
169
  if (message.author?.bot) {
166
170
  return
167
171
  }
172
+
173
+ // Ignore messages that start with a mention of another user (not the bot).
174
+ // These are likely users talking to each other, not the bot.
175
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
176
+ if (leadingMentionMatch) {
177
+ const mentionedUserId = leadingMentionMatch[1]
178
+ if (mentionedUserId !== discordClient.user?.id) {
179
+ return
180
+ }
181
+ }
182
+
168
183
  if (message.partial) {
169
184
  discordLogger.log(`Fetching partial message ${message.id}`)
170
185
  const fetched = await errore.tryAsync({
@@ -275,13 +290,19 @@ export async function startDiscordBot({
275
290
 
276
291
  // Include starter message as context for the session
277
292
  let prompt = message.content
278
- const starterMessage = await thread.fetchStarterMessage().catch(() => null)
293
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
294
+ discordLogger.warn(
295
+ `[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
296
+ error instanceof Error ? error.message : String(error),
297
+ )
298
+ return null
299
+ })
279
300
  if (starterMessage?.content && starterMessage.content !== message.content) {
280
301
  prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
281
302
  }
282
303
 
283
304
  await handleOpencodeSession({
284
- prompt,
305
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt }),
285
306
  thread,
286
307
  projectDirectory,
287
308
  channelId: parent?.id || '',
@@ -358,7 +379,7 @@ export async function startDiscordBot({
358
379
  ? `${messageContent}\n\n${textAttachmentsContent}`
359
380
  : messageContent
360
381
  await handleOpencodeSession({
361
- prompt: promptWithAttachments,
382
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
362
383
  thread,
363
384
  projectDirectory,
364
385
  originalMessage: message,
@@ -502,7 +523,7 @@ export async function startDiscordBot({
502
523
  ? `${messageContent}\n\n${textAttachmentsContent}`
503
524
  : messageContent
504
525
  await handleOpencodeSession({
505
- prompt: promptWithAttachments,
526
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
506
527
  thread,
507
528
  projectDirectory: sessionDirectory,
508
529
  originalMessage: message,
@@ -517,8 +538,11 @@ export async function startDiscordBot({
517
538
  try {
518
539
  const errMsg = error instanceof Error ? error.message : String(error)
519
540
  await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
520
- } catch {
521
- voiceLogger.error('Discord handler error (fallback):', error)
541
+ } catch (sendError) {
542
+ voiceLogger.error(
543
+ 'Discord handler error (fallback):',
544
+ sendError instanceof Error ? sendError.message : String(sendError),
545
+ )
522
546
  }
523
547
  }
524
548
  })
@@ -539,7 +563,13 @@ export async function startDiscordBot({
539
563
  }
540
564
 
541
565
  // Get the starter message to check for auto-start marker
542
- const starterMessage = await thread.fetchStarterMessage().catch(() => null)
566
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
567
+ discordLogger.warn(
568
+ `[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
569
+ error instanceof Error ? error.message : String(error),
570
+ )
571
+ return null
572
+ })
543
573
  if (!starterMessage) {
544
574
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
545
575
  return
@@ -601,8 +631,11 @@ export async function startDiscordBot({
601
631
  try {
602
632
  const errMsg = error instanceof Error ? error.message : String(error)
603
633
  await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
604
- } catch {
605
- // Ignore send errors
634
+ } catch (sendError) {
635
+ voiceLogger.error(
636
+ '[BOT_SESSION] Failed to send error message:',
637
+ sendError instanceof Error ? sendError.message : String(sendError),
638
+ )
606
639
  }
607
640
  }
608
641
  })
@@ -8,12 +8,12 @@ import { formatMarkdownTables } from './format-tables.js'
8
8
  import { getChannelDirectory } from './database.js'
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js'
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
11
- import { createLogger } from './logger.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
12
  import mime from 'mime'
13
13
  import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
 
16
- const discordLogger = createLogger('DISCORD')
16
+ const discordLogger = createLogger(LogPrefix.DISCORD)
17
17
 
18
18
  export const SILENT_MESSAGE_FLAGS = 4 | 4096
19
19
  // Same as SILENT but without SuppressNotifications - triggers badge/notification
@@ -5,10 +5,10 @@
5
5
  import { Worker } from 'node:worker_threads'
6
6
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
7
7
  import type { Tool as AITool } from 'ai'
8
- import { createLogger } from './logger.js'
8
+ import { createLogger, LogPrefix } from './logger.js'
9
9
 
10
- const genaiWorkerLogger = createLogger('GENAI WORKER')
11
- const genaiWrapperLogger = createLogger('GENAI WORKER WRAPPER')
10
+ const genaiWorkerLogger = createLogger(LogPrefix.GENAI_WORKER)
11
+ const genaiWrapperLogger = createLogger(LogPrefix.GENAI_WORKER)
12
12
 
13
13
  export interface GenAIWorkerOptions {
14
14
  directory: string
@@ -13,13 +13,13 @@ import type { Session } from '@google/genai'
13
13
  import { getTools } from './tools.js'
14
14
  import { mkdir } from 'node:fs/promises'
15
15
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
16
- import { createLogger } from './logger.js'
16
+ import { createLogger, LogPrefix } from './logger.js'
17
17
 
18
18
  if (!parentPort) {
19
19
  throw new Error('This module must be run as a worker thread')
20
20
  }
21
21
 
22
- const workerLogger = createLogger(`WORKER ${threadId}`)
22
+ const workerLogger = createLogger(`${LogPrefix.WORKER}_${threadId}`)
23
23
  workerLogger.log('GenAI worker started')
24
24
 
25
25
  // Define sendError early so it can be used by global handlers
package/src/genai.ts CHANGED
@@ -7,10 +7,10 @@ import type { CallableTool } from '@google/genai'
7
7
  import { writeFile } from 'fs'
8
8
  import type { Tool as AITool } from 'ai'
9
9
 
10
- import { createLogger } from './logger.js'
10
+ import { createLogger, LogPrefix } from './logger.js'
11
11
  import { aiToolToCallableTool } from './ai-tool-to-genai.js'
12
12
 
13
- const genaiLogger = createLogger('GENAI')
13
+ const genaiLogger = createLogger(LogPrefix.GENAI)
14
14
 
15
15
  const audioParts: Buffer[] = []
16
16
 
@@ -19,6 +19,7 @@ import {
19
19
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js'
20
20
  import { handlePermissionSelectMenu } from './commands/permissions.js'
21
21
  import { handleAbortCommand } from './commands/abort.js'
22
+ import { handleCompactCommand } from './commands/compact.js'
22
23
  import { handleShareCommand } from './commands/share.js'
23
24
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
24
25
  import {
@@ -32,9 +33,9 @@ import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js
32
33
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
33
34
  import { handleUserCommand } from './commands/user-command.js'
34
35
  import { handleVerbosityCommand } from './commands/verbosity.js'
35
- import { createLogger } from './logger.js'
36
+ import { createLogger, LogPrefix } from './logger.js'
36
37
 
37
- const interactionLogger = createLogger('INTERACTION')
38
+ const interactionLogger = createLogger(LogPrefix.INTERACTION)
38
39
 
39
40
  export function registerInteractionHandler({
40
41
  discordClient,
@@ -126,6 +127,10 @@ export function registerInteractionHandler({
126
127
  await handleAbortCommand({ command: interaction, appId })
127
128
  return
128
129
 
130
+ case 'compact':
131
+ await handleCompactCommand({ command: interaction, appId })
132
+ return
133
+
129
134
  case 'share':
130
135
  await handleShareCommand({ command: interaction, appId })
131
136
  return
package/src/logger.ts CHANGED
@@ -1,12 +1,54 @@
1
- // Prefixed logging utility using @clack/prompts.
2
- // Creates loggers with consistent prefixes for different subsystems
3
- // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
1
+ // Prefixed logging utility.
2
+ // Uses picocolors for compact frequent logs (log, info, debug).
3
+ // Uses @clack/prompts only for important events (warn, error) with visual distinction.
4
4
 
5
- import { log } from '@clack/prompts'
5
+ import { log as clackLog } from '@clack/prompts'
6
6
  import fs from 'node:fs'
7
7
  import path, { dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import util from 'node:util'
10
+ import pc from 'picocolors'
11
+
12
+ // All known log prefixes - add new ones here to keep alignment consistent
13
+ export const LogPrefix = {
14
+ ABORT: 'ABORT',
15
+ ADD_PROJECT: 'ADD_PROJ',
16
+ AGENT: 'AGENT',
17
+ ASK_QUESTION: 'QUESTION',
18
+ CLI: 'CLI',
19
+ COMPACT: 'COMPACT',
20
+ CREATE_PROJECT: 'NEW_PROJ',
21
+ DB: 'DB',
22
+ DISCORD: 'DISCORD',
23
+ FORK: 'FORK',
24
+ FORMATTING: 'FORMAT',
25
+ GENAI: 'GENAI',
26
+ GENAI_WORKER: 'GENAI_W',
27
+ INTERACTION: 'INTERACT',
28
+ MARKDOWN: 'MARKDOWN',
29
+ MODEL: 'MODEL',
30
+ OPENAI: 'OPENAI',
31
+ OPENCODE: 'OPENCODE',
32
+ PERMISSIONS: 'PERMS',
33
+ QUEUE: 'QUEUE',
34
+ REMOVE_PROJECT: 'RM_PROJ',
35
+ RESUME: 'RESUME',
36
+ SESSION: 'SESSION',
37
+ SHARE: 'SHARE',
38
+ TOOLS: 'TOOLS',
39
+ UNDO_REDO: 'UNDO',
40
+ USER_CMD: 'USER_CMD',
41
+ VERBOSITY: 'VERBOSE',
42
+ VOICE: 'VOICE',
43
+ WORKER: 'WORKER',
44
+ WORKTREE: 'WORKTREE',
45
+ XML: 'XML',
46
+ } as const
47
+
48
+ export type LogPrefixType = (typeof LogPrefix)[keyof typeof LogPrefix]
49
+
50
+ // compute max length from all known prefixes for alignment
51
+ const MAX_PREFIX_LENGTH = Math.max(...Object.values(LogPrefix).map((p) => p.length))
10
52
 
11
53
  const __filename = fileURLToPath(import.meta.url)
12
54
  const __dirname = dirname(__filename)
@@ -39,27 +81,39 @@ function writeToFile(level: string, prefix: string, args: unknown[]) {
39
81
  fs.appendFileSync(logFilePath, message)
40
82
  }
41
83
 
42
- export function createLogger(prefix: string) {
84
+ function getTimestamp(): string {
85
+ const now = new Date()
86
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
87
+ }
88
+
89
+ function padPrefix(prefix: string): string {
90
+ return prefix.padEnd(MAX_PREFIX_LENGTH)
91
+ }
92
+
93
+ export function createLogger(prefix: LogPrefixType | string) {
94
+ const paddedPrefix = padPrefix(prefix)
43
95
  return {
44
96
  log: (...args: unknown[]) => {
45
97
  writeToFile('INFO', prefix, args)
46
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
98
+ console.log(pc.dim(getTimestamp()), pc.cyan(paddedPrefix), ...args.map(formatArg))
47
99
  },
48
100
  error: (...args: unknown[]) => {
49
101
  writeToFile('ERROR', prefix, args)
50
- log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '))
102
+ // use clack for errors - visually distinct
103
+ clackLog.error([paddedPrefix, ...args.map(formatArg)].join(' '))
51
104
  },
52
105
  warn: (...args: unknown[]) => {
53
106
  writeToFile('WARN', prefix, args)
54
- log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '))
107
+ // use clack for warnings - visually distinct
108
+ clackLog.warn([paddedPrefix, ...args.map(formatArg)].join(' '))
55
109
  },
56
110
  info: (...args: unknown[]) => {
57
111
  writeToFile('INFO', prefix, args)
58
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
112
+ console.log(pc.dim(getTimestamp()), pc.blue(paddedPrefix), ...args.map(formatArg))
59
113
  },
60
114
  debug: (...args: unknown[]) => {
61
115
  writeToFile('DEBUG', prefix, args)
62
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
116
+ console.log(pc.dim(getTimestamp()), pc.dim(paddedPrefix), ...args.map(formatArg))
63
117
  },
64
118
  }
65
119
  }
package/src/markdown.ts CHANGED
@@ -9,7 +9,7 @@ import { createTaggedError } from 'errore'
9
9
  import * as yaml from 'js-yaml'
10
10
  import { formatDateTime } from './utils.js'
11
11
  import { extractNonXmlContent } from './xml.js'
12
- import { createLogger } from './logger.js'
12
+ import { createLogger, LogPrefix } from './logger.js'
13
13
  import { SessionNotFoundError, MessagesNotFoundError } from './errors.js'
14
14
 
15
15
  // Generic error for unexpected exceptions in async operations
@@ -18,7 +18,7 @@ class UnexpectedError extends createTaggedError({
18
18
  message: '$message',
19
19
  }) {}
20
20
 
21
- const markdownLogger = createLogger('MARKDOWN')
21
+ const markdownLogger = createLogger(LogPrefix.MARKDOWN)
22
22
 
23
23
  export class ShareMarkdown {
24
24
  constructor(private client: OpencodeClient) {}
@@ -8,7 +8,7 @@ import type { Message } from 'discord.js'
8
8
  import fs from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import * as errore from 'errore'
11
- import { createLogger } from './logger.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
12
  import { FetchError } from './errors.js'
13
13
 
14
14
  // Generic message type compatible with both v1 and v2 SDK
@@ -19,7 +19,7 @@ type GenericSessionMessage = {
19
19
 
20
20
  const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments')
21
21
 
22
- const logger = createLogger('FORMATTING')
22
+ const logger = createLogger(LogPrefix.FORMATTING)
23
23
 
24
24
  /**
25
25
  * Escapes Discord inline markdown characters so dynamic content
@@ -29,6 +29,81 @@ function escapeInlineMarkdown(text: string): string {
29
29
  return text.replace(/([*_~|`\\])/g, '\\$1')
30
30
  }
31
31
 
32
+ /**
33
+ * Parses a patchText string (apply_patch format) and counts additions/deletions per file.
34
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
35
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
36
+ */
37
+ function parsePatchCounts(
38
+ patchText: string,
39
+ ): Map<string, { additions: number; deletions: number }> {
40
+ const counts = new Map<string, { additions: number; deletions: number }>()
41
+ const lines = patchText.split('\n')
42
+ let currentFile = ''
43
+ let currentType = ''
44
+ let inHunk = false
45
+
46
+ for (const line of lines) {
47
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
48
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
49
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
50
+
51
+ if (addMatch || updateMatch || deleteMatch) {
52
+ const match = addMatch || updateMatch || deleteMatch
53
+ currentFile = (match?.[1] ?? '').trim()
54
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
55
+ counts.set(currentFile, { additions: 0, deletions: 0 })
56
+ inHunk = false
57
+ continue
58
+ }
59
+
60
+ if (line.startsWith('@@')) {
61
+ inHunk = true
62
+ continue
63
+ }
64
+
65
+ if (line.startsWith('*** ')) {
66
+ inHunk = false
67
+ continue
68
+ }
69
+
70
+ if (!currentFile) {
71
+ continue
72
+ }
73
+
74
+ const entry = counts.get(currentFile)
75
+ if (!entry) {
76
+ continue
77
+ }
78
+
79
+ if (currentType === 'add') {
80
+ // all content lines in Add File are additions
81
+ if (line.length > 0 && !line.startsWith('*** ')) {
82
+ entry.additions++
83
+ }
84
+ } else if (currentType === 'delete') {
85
+ // all content lines in Delete File are deletions
86
+ if (line.length > 0 && !line.startsWith('*** ')) {
87
+ entry.deletions++
88
+ }
89
+ } else if (inHunk) {
90
+ if (line.startsWith('+')) {
91
+ entry.additions++
92
+ } else if (line.startsWith('-')) {
93
+ entry.deletions++
94
+ }
95
+ }
96
+ }
97
+ return counts
98
+ }
99
+
100
+ /**
101
+ * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
102
+ */
103
+ function normalizeWhitespace(text: string): string {
104
+ return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ')
105
+ }
106
+
32
107
  /**
33
108
  * Collects and formats the last N assistant parts from session messages.
34
109
  * Used by both /resume and /fork to show recent assistant context.
@@ -170,6 +245,23 @@ export function getToolSummaryText(part: Part): string {
170
245
  : `(+${added}-${removed})`
171
246
  }
172
247
 
248
+ if (part.tool === 'apply_patch') {
249
+ // Only inputs are available when parts are sent during streaming (output/metadata not yet populated)
250
+ const patchText = (part.state.input?.patchText as string) || ''
251
+ if (!patchText) {
252
+ return ''
253
+ }
254
+ const patchCounts = parsePatchCounts(patchText)
255
+ return [...patchCounts.entries()]
256
+ .map(([filePath, { additions, deletions }]) => {
257
+ const fileName = filePath.split('/').pop() || ''
258
+ return fileName
259
+ ? `*${escapeInlineMarkdown(fileName)}* (+${additions}-${deletions})`
260
+ : `(+${additions}-${deletions})`
261
+ })
262
+ .join(', ')
263
+ }
264
+
173
265
  if (part.tool === 'write') {
174
266
  const filePath = (part.state.input?.filePath as string) || ''
175
267
  const content = (part.state.input?.content as string) || ''
@@ -228,7 +320,8 @@ export function getToolSummaryText(part: Part): string {
228
320
  .map(([key, value]) => {
229
321
  if (value === null || value === undefined) return null
230
322
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
231
- const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue
323
+ const normalized = normalizeWhitespace(stringValue)
324
+ const truncatedValue = normalized.length > 50 ? normalized.slice(0, 50) + '…' : normalized
232
325
  return `${key}: ${truncatedValue}`
233
326
  })
234
327
  .filter(Boolean)
@@ -259,7 +352,7 @@ export function formatTodoList(part: Part): string {
259
352
  }
260
353
 
261
354
  export function formatPart(part: Part, prefix?: string): string {
262
- const pfx = prefix ? `${prefix}: ` : ''
355
+ const pfx = prefix ? `${prefix} ` : ''
263
356
 
264
357
  if (part.type === 'text') {
265
358
  if (!part.text?.trim()) return ''
@@ -343,12 +436,13 @@ export function formatPart(part: Part, prefix?: string): string {
343
436
  if (part.state.status === 'error') {
344
437
  return '⨯'
345
438
  }
346
- if (part.tool === 'edit' || part.tool === 'write') {
439
+ if (part.tool === 'edit' || part.tool === 'write' || part.tool === 'apply_patch') {
347
440
  return '◼︎'
348
441
  }
349
442
  return '┣'
350
443
  })()
351
- return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim()
444
+ const toolParts = [part.tool, toolTitle, summaryText].filter(Boolean).join(' ')
445
+ return `${icon} ${pfx}${toolParts}`
352
446
  }
353
447
 
354
448
  logger.warn('Unknown part type:', part)