kimaki 0.18.0 → 0.20.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/src/markdown.ts CHANGED
@@ -32,8 +32,10 @@ export class ShareMarkdown {
32
32
  sessionID: string
33
33
  includeSystemInfo?: boolean
34
34
  lastAssistantOnly?: boolean
35
+ /** When true (default), tool calls show a compact one-liner with line count instead of full output. */
36
+ compactTools?: boolean
35
37
  }): Promise<SessionNotFoundError | MessagesNotFoundError | string> {
36
- const { sessionID, includeSystemInfo, lastAssistantOnly } = options
38
+ const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options
37
39
 
38
40
  // Get session info
39
41
  const sessionResponse = await this.client.session.get({
@@ -96,7 +98,7 @@ export class ShareMarkdown {
96
98
  }
97
99
 
98
100
  for (const message of messagesToRender) {
99
- const messageLines = this.renderMessage(message!.info, message!.parts)
101
+ const messageLines = this.renderMessage(message!.info, message!.parts, { compactTools })
100
102
  lines.push(...messageLines)
101
103
  lines.push('')
102
104
  }
@@ -104,7 +106,7 @@ export class ShareMarkdown {
104
106
  return lines.join('\n')
105
107
  }
106
108
 
107
- private renderMessage(message: any, parts: any[]): string[] {
109
+ private renderMessage(message: any, parts: any[], opts: { compactTools: boolean }): string[] {
108
110
  const lines: string[] = []
109
111
 
110
112
  if (message.role === 'user') {
@@ -148,7 +150,9 @@ export class ShareMarkdown {
148
150
  })
149
151
 
150
152
  for (const part of filteredParts) {
151
- const partLines = this.renderPart(part, message)
153
+ const partLines = opts.compactTools
154
+ ? this.renderPartCompact(part, message)
155
+ : this.renderPart(part, message)
152
156
  lines.push(...partLines)
153
157
  }
154
158
 
@@ -251,6 +255,61 @@ export class ShareMarkdown {
251
255
  return lines
252
256
  }
253
257
 
258
+ /** Compact rendering: tool calls become a single line with line count instead of full output. */
259
+ private renderPartCompact(part: any, message: any): string[] {
260
+ // Non-tool parts render the same as verbose
261
+ if (part.type !== 'tool') {
262
+ return this.renderPart(part, message)
263
+ }
264
+
265
+ const lines: string[] = []
266
+
267
+ if (part.state.status === 'completed') {
268
+ const output: string = part.state.output || ''
269
+ const lineCount = output ? output.split('\n').length : 0
270
+ const inputSummary = this.compactInputSummary(part.state.input)
271
+ const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : ''
272
+ const parts = [inputSummary, outputLabel].filter(Boolean).join(' ')
273
+ lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`)
274
+ lines.push('')
275
+ } else if (part.state.status === 'error') {
276
+ const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120)
277
+ lines.push(`> ❌ **${part.tool}** — ${errorText}`)
278
+ lines.push('')
279
+ }
280
+
281
+ return lines
282
+ }
283
+
284
+ /** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
285
+ private compactInputSummary(input: Record<string, unknown> | undefined): string {
286
+ if (!input) return ''
287
+ const entries = Object.entries(input)
288
+ if (entries.length === 0) return ''
289
+
290
+ const parts: string[] = []
291
+ const maxParams = 2
292
+ for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
293
+ const [key, value] = entries[i]!
294
+ let val: string
295
+ try {
296
+ val = typeof value === 'string'
297
+ ? value
298
+ : (JSON.stringify(value) ?? String(value))
299
+ } catch {
300
+ val = String(value)
301
+ }
302
+ // Collapse whitespace for readability
303
+ const collapsed = val.replace(/\s+/g, ' ').trim()
304
+ const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed
305
+ parts.push(`${key}=${normalized}`)
306
+ }
307
+ if (entries.length > maxParams) {
308
+ parts.push('...')
309
+ }
310
+ return parts.join(', ')
311
+ }
312
+
254
313
  private formatDuration(ms: number): string {
255
314
  if (ms < 1000) return `${ms}ms`
256
315
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'vitest'
2
- import { formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js'
2
+ import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js'
3
3
  import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
4
4
  import type { Part } from '@opencode-ai/sdk/v2'
5
5
 
@@ -48,6 +48,65 @@ describe('formatPart', () => {
48
48
  })
49
49
  })
50
50
 
51
+ describe('formatBashToolTitle', () => {
52
+ test('short single-line command shown in full', () => {
53
+ expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`)
54
+ })
55
+
56
+ test('multiline command without description truncates to first line', () => {
57
+ expect(
58
+ formatBashToolTitle({ command: 'echo hello\necho world\necho done' }),
59
+ ).toMatchInlineSnapshot(`" _echo hello_"`)
60
+ })
61
+
62
+ test('long single-line command is truncated with ellipsis', () => {
63
+ const longCommand = 'a'.repeat(150)
64
+ const result = formatBashToolTitle({ command: longCommand })
65
+ expect(result).toContain('…')
66
+ expect(result.length).toBeLessThan(150)
67
+ })
68
+
69
+ test('description is preferred over truncated command when present', () => {
70
+ expect(
71
+ formatBashToolTitle({
72
+ command: 'echo hello\necho world',
73
+ description: 'Print greeting',
74
+ }),
75
+ ).toMatchInlineSnapshot(`" _Print greeting_"`)
76
+ })
77
+
78
+ test('stateTitle used as last resort', () => {
79
+ expect(
80
+ formatBashToolTitle({ command: '', stateTitle: 'Running tests' }),
81
+ ).toMatchInlineSnapshot(`" _Running tests_"`)
82
+ })
83
+
84
+ test('empty inputs return empty string', () => {
85
+ expect(formatBashToolTitle({ command: '' })).toBe('')
86
+ })
87
+
88
+ test('leading blank line skipped, uses first meaningful line', () => {
89
+ expect(
90
+ formatBashToolTitle({ command: '\npnpm test\npnpm build' }),
91
+ ).toMatchInlineSnapshot(`" _pnpm test_"`)
92
+ })
93
+
94
+ test('whitespace-only first line skipped', () => {
95
+ expect(
96
+ formatBashToolTitle({ command: ' \npnpm test' }),
97
+ ).toMatchInlineSnapshot(`" _pnpm test_"`)
98
+ })
99
+
100
+ test('no description field (new opencode) with multiline command', () => {
101
+ // This is the exact scenario that was broken: opencode removed `description`
102
+ // from the bash tool schema, so multiline commands rendered as just "┣ bash"
103
+ const command = 'git diff HEAD~1 --stat && git log --oneline -5'
104
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(
105
+ `" _git diff HEAD\\~1 --stat && git log --oneline -5_"`,
106
+ )
107
+ })
108
+ })
109
+
51
110
  describe('formatTodoList', () => {
52
111
  test('formats active todo with monospace numbers', () => {
53
112
  const part: Part = {
@@ -350,6 +350,54 @@ export async function getFileAttachments(
350
350
 
351
351
  const MAX_BASH_COMMAND_INLINE_LENGTH = 100
352
352
 
353
+ /**
354
+ * Format the inline title for a bash tool part. Handles three cases:
355
+ * 1. Short single-line command → show full command
356
+ * 2. Long/multiline command with description → show description
357
+ * 3. Long/multiline command without description → truncate first line of command
358
+ *
359
+ * The description field was removed from the bash tool schema in newer opencode
360
+ * versions, so case 3 is the common path now. Without this fallback, long commands
361
+ * would render as just "┣ bash" with no context.
362
+ */
363
+ export function formatBashToolTitle({
364
+ command,
365
+ description,
366
+ stateTitle,
367
+ }: {
368
+ command: string
369
+ description?: string
370
+ stateTitle?: string
371
+ }): string {
372
+ if (!command && !description && !stateTitle) return ''
373
+
374
+ const isSingleLine = !command.includes('\n')
375
+ // Find first non-empty line to handle commands with leading blank lines
376
+ const firstMeaningfulLine =
377
+ command
378
+ .split('\n')
379
+ .find((line) => line.trim().length > 0)
380
+ ?.trimStart() ?? ''
381
+
382
+ if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
383
+ return ` _${escapeInlineMarkdown(command)}_`
384
+ }
385
+ if (description) {
386
+ return ` _${escapeInlineMarkdown(description)}_`
387
+ }
388
+ if (firstMeaningfulLine.length > 0) {
389
+ const truncated =
390
+ firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
391
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
392
+ : firstMeaningfulLine
393
+ return ` _${escapeInlineMarkdown(truncated)}_`
394
+ }
395
+ if (stateTitle) {
396
+ return ` _${escapeInlineMarkdown(stateTitle)}_`
397
+ }
398
+ return ''
399
+ }
400
+
353
401
  export function getToolSummaryText(part: Part): string {
354
402
  if (part.type !== 'tool') return ''
355
403
 
@@ -568,13 +616,10 @@ export function formatPart(part: Part, prefix?: string): string {
568
616
  }
569
617
  const command = (part.state.input?.command as string) || ''
570
618
  const description = (part.state.input?.description as string) || ''
571
- const isSingleLine = !command.includes('\n')
572
- const toolTitle =
573
- isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH
574
- ? ` _${escapeInlineMarkdown(command)}_`
575
- : description
576
- ? ` _${escapeInlineMarkdown(description)}_`
577
- : ''
619
+ const toolTitle = formatBashToolTitle({
620
+ command,
621
+ description,
622
+ })
578
623
  return `┣ ${pfx}bash${toolTitle}`
579
624
  }
580
625
 
@@ -587,14 +632,12 @@ export function formatPart(part: Part, prefix?: string): string {
587
632
  } else if (part.tool === 'bash') {
588
633
  const command = (part.state.input?.command as string) || ''
589
634
  const description = (part.state.input?.description as string) || ''
590
- const isSingleLine = !command.includes('\n')
591
- if (isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
592
- toolTitle = `_${escapeInlineMarkdown(command)}_`
593
- } else if (description) {
594
- toolTitle = `_${escapeInlineMarkdown(description)}_`
595
- } else if (stateTitle) {
596
- toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
597
- }
635
+ const formatted = formatBashToolTitle({
636
+ command,
637
+ description,
638
+ stateTitle,
639
+ })
640
+ toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted
598
641
  } else if (stateTitle) {
599
642
  toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
600
643
  }
@@ -365,23 +365,24 @@ function getTokenTotal(tokens: TokenUsage): number {
365
365
  )
366
366
  }
367
367
 
368
+ /**
369
+ * Built-in read-only tools that are hidden in default verbosity mode.
370
+ * Any tool NOT in this list is considered "essential" and shown,
371
+ * which means custom tools, MCP tools, and plugin tools are visible by default.
372
+ */
373
+ const HIDDEN_READONLY_TOOLS = [
374
+ 'read',
375
+ 'glob',
376
+ 'grep',
377
+ 'describe-media',
378
+ 'todoread',
379
+ ]
380
+
368
381
  /** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
369
382
  export function isEssentialToolName(toolName: string): boolean {
370
- const essentialTools = [
371
- 'edit',
372
- 'write',
373
- 'apply_patch',
374
- 'bash',
375
- 'webfetch',
376
- 'websearch',
377
- 'googlesearch',
378
- 'codesearch',
379
- 'task',
380
- 'todowrite',
381
- 'skill',
382
- ]
383
- // Also match any MCP tool that contains these names
384
- return essentialTools.some((name) => {
383
+ // Hide known read-only built-in tools; show everything else
384
+ // (custom tools, MCP tools, plugin tools are visible by default)
385
+ return !HIDDEN_READONLY_TOOLS.some((name) => {
385
386
  return toolName === name || toolName.endsWith(`_${name}`)
386
387
  })
387
388
  }
@@ -559,6 +560,13 @@ export type IngressInput = {
559
560
  sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
560
561
  /** Optional guard for retries: skip enqueue when session has changed. */
561
562
  expectedSessionId?: string
563
+ /**
564
+ * When true, the message is added to the session context without triggering
565
+ * the AI agent loop. Used for messages that should be visible to the model
566
+ * on the next real turn but should not cause a response on their own
567
+ * (e.g. user-to-user replies in a thread).
568
+ */
569
+ noReply?: boolean
562
570
  /**
563
571
  * Lazy preprocessing callback. When set, the runtime serializes it via a
564
572
  * lightweight promise chain (preprocessChain) to resolve prompt/images/mode
@@ -2838,6 +2846,18 @@ export class ThreadSessionRuntime {
2838
2846
  return
2839
2847
  }
2840
2848
 
2849
+ // Context-only messages (noReply) should not create a new session.
2850
+ // If there is no existing session, silently skip.
2851
+ if (input.noReply) {
2852
+ const existingSessionId = this.state?.sessionId || await getThreadSession(this.thread.id) || undefined
2853
+ if (!existingSessionId) {
2854
+ logger.log(
2855
+ `[INGRESS] Skipping noReply message for thread ${this.threadId}: no existing session`,
2856
+ )
2857
+ return
2858
+ }
2859
+ }
2860
+
2841
2861
  // Helper: stop typing and drain queued local messages on error.
2842
2862
  const cleanupOnError = async (errorMessage: string) => {
2843
2863
  this.stopTyping()
@@ -3063,6 +3083,7 @@ export class ThreadSessionRuntime {
3063
3083
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
3064
3084
  ...(modelField ? { model: modelField } : {}),
3065
3085
  ...variantField,
3086
+ ...(input.noReply ? { noReply: true } : {}),
3066
3087
  }
3067
3088
  const promptResult = await getClient().session.promptAsync(request)
3068
3089
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }))
@@ -3082,7 +3103,10 @@ export class ThreadSessionRuntime {
3082
3103
  `[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`,
3083
3104
  )
3084
3105
 
3085
- this.markQueueDispatchBusy(session.id)
3106
+ // noReply messages don't trigger the agent loop, so don't mark as busy
3107
+ if (!input.noReply) {
3108
+ this.markQueueDispatchBusy(session.id)
3109
+ }
3086
3110
  })
3087
3111
 
3088
3112
  if (skippedBySessionGuard) {
@@ -3241,8 +3265,15 @@ export class ThreadSessionRuntime {
3241
3265
  // Route with the resolved mode through normal paths.
3242
3266
  // Await the enqueue so session state (ensureSession, setThreadSession)
3243
3267
  // is persisted before the next message's preprocessing reads it.
3244
- const enqueueResult =
3245
- resolvedInput.mode === 'local-queue' || resolvedInput.command
3268
+ // noReply messages always go through the opencode path so the flag
3269
+ // reaches promptAsync; local queue doesn't support noReply.
3270
+ const enqueueResult = resolvedInput.noReply
3271
+ ? await this.submitViaOpencodeQueue({
3272
+ ...resolvedInput,
3273
+ mode: 'opencode',
3274
+ command: undefined,
3275
+ })
3276
+ : (resolvedInput.mode === 'local-queue' || resolvedInput.command)
3246
3277
  ? await this.enqueueViaLocalQueue(resolvedInput)
3247
3278
  : await this.submitViaOpencodeQueue(resolvedInput)
3248
3279
  resolveOuter(enqueueResult)
package/src/store.ts CHANGED
@@ -110,6 +110,13 @@ export type KimakiState = {
110
110
  // Read by: cli-runner.ts run() before calling backgroundUpgradeKimaki().
111
111
  autoUpgradeEnabled: boolean
112
112
 
113
+ // When true, all new sessions from channel messages create git worktrees.
114
+ // Set once at startup from --worktrees CLI flag. The per-channel toggle
115
+ // (getChannelWorktreesEnabled) is checked separately; this is the global override.
116
+ // Changes: set once at startup.
117
+ // Read by: discord-bot.ts message handler, commands/agent.ts quick-agent with prompt.
118
+ useWorktrees: boolean
119
+
113
120
  // Whether background sync of external OpenCode sessions is enabled.
114
121
  // When true (default), sessions started from the OpenCode CLI or TUI
115
122
  // are mirrored into Discord threads so they can be browsed, searched,
@@ -172,6 +179,7 @@ export const store = createStore<KimakiState>(() => ({
172
179
  allowedMentions: ['users'],
173
180
  allowAllUsers: false,
174
181
  permissionTimeoutMs: 10 * 60 * 1000,
182
+ useWorktrees: false,
175
183
  autoUpgradeEnabled: true,
176
184
  syncEnabled: true,
177
185
  discordBaseUrl: 'https://discord.com',