kimaki 0.19.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/dist/cli-commands/project.js +85 -87
- package/dist/commands/agent.js +51 -4
- package/dist/discord-bot.js +24 -10
- package/dist/message-formatting.js +46 -16
- package/dist/message-formatting.test.js +39 -1
- package/dist/session-handler/thread-session-runtime.js +40 -19
- package/dist/store.js +1 -0
- package/package.json +3 -3
- package/skills/goke/SKILL.md +17 -0
- package/skills/holocron/SKILL.md +66 -1
- package/skills/zele/SKILL.md +13 -0
- package/src/cli-commands/project.ts +99 -87
- package/src/commands/agent.ts +64 -3
- package/src/discord-bot.ts +29 -10
- package/src/message-formatting.test.ts +60 -1
- package/src/message-formatting.ts +58 -15
- package/src/session-handler/thread-session-runtime.ts +49 -18
- package/src/store.ts +8 -0
|
@@ -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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3245
|
-
|
|
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',
|