kimaki 1.3.0 → 1.5.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/assets/fonts/Geist-Bold.ttf +0 -0
- package/assets/fonts/Geist-Regular.ttf +0 -0
- package/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/assets/fonts/OFL.txt +92 -0
- package/dist/agent-ui.e2e.test.js +27 -13
- package/dist/agent-ui.e2e.test.js.map +1 -1
- package/dist/agent-ui.js +2 -1
- package/dist/agent-ui.js.map +1 -1
- package/dist/analytics.e2e.test.js +1 -1
- package/dist/analytics.e2e.test.js.map +1 -1
- package/dist/autostart.d.ts +4 -0
- package/dist/autostart.js +55 -10
- package/dist/autostart.js.map +1 -1
- package/dist/bot.d.ts +13 -6
- package/dist/bot.js +17 -15
- package/dist/bot.js.map +1 -1
- package/dist/btw.e2e.test.js +3 -3
- package/dist/builtin-skills.d.ts +3 -0
- package/dist/builtin-skills.js +29 -0
- package/dist/builtin-skills.js.map +1 -1
- package/dist/builtin-skills.test.js +72 -1
- package/dist/builtin-skills.test.js.map +1 -1
- package/dist/catalog-refresh.e2e.test.js +2 -2
- package/dist/cli/bot.d.ts +1 -1
- package/dist/cli/bot.js +33 -18
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/discord.js +32 -6
- package/dist/cli/discord.js.map +1 -1
- package/dist/cli/schedule.js +23 -9
- package/dist/cli/schedule.js.map +1 -1
- package/dist/cli/session.js +37 -12
- package/dist/cli/session.js.map +1 -1
- package/dist/cli/shared.d.ts +5 -3
- package/dist/cli/shared.js +45 -10
- package/dist/cli/shared.js.map +1 -1
- package/dist/cli-discord.e2e.test.js +13 -8
- package/dist/cli-discord.e2e.test.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +87 -20
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/cli-read.e2e.test.js +18 -9
- package/dist/cli-read.e2e.test.js.map +1 -1
- package/dist/cli-sessions.e2e.test.js +4 -3
- package/dist/cli-sessions.e2e.test.js.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/preference-commands.d.ts +4 -0
- package/dist/commands/preference-commands.js +38 -4
- package/dist/commands/preference-commands.js.map +1 -1
- package/dist/commands/session-commands.js +11 -8
- package/dist/commands/session-commands.js.map +1 -1
- package/dist/commands/thread-commands.js +94 -24
- package/dist/commands/thread-commands.js.map +1 -1
- package/dist/commands/worktree-commands.d.ts +5 -6
- package/dist/commands/worktree-commands.js +8 -7
- package/dist/commands/worktree-commands.js.map +1 -1
- package/dist/commands.e2e.test.js +25 -21
- package/dist/commands.e2e.test.js.map +1 -1
- package/dist/coordinator-agents.e2e.test.d.ts +1 -0
- package/dist/coordinator-agents.e2e.test.js +113 -0
- package/dist/coordinator-agents.e2e.test.js.map +1 -0
- package/dist/coordinator-idle.e2e.test.d.ts +1 -0
- package/dist/coordinator-idle.e2e.test.js +100 -0
- package/dist/coordinator-idle.e2e.test.js.map +1 -0
- package/dist/coordinator-queue.e2e.test.d.ts +1 -0
- package/dist/coordinator-queue.e2e.test.js +244 -0
- package/dist/coordinator-queue.e2e.test.js.map +1 -0
- package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
- package/dist/coordinator-warmup.e2e.test.js +131 -0
- package/dist/coordinator-warmup.e2e.test.js.map +1 -0
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.e2e.test.d.ts +1 -0
- package/dist/coordinator.e2e.test.js +505 -0
- package/dist/coordinator.e2e.test.js.map +1 -0
- package/dist/coordinator.js +164 -0
- package/dist/coordinator.js.map +1 -0
- package/dist/db.d.ts +78 -3
- package/dist/db.test.js +1 -0
- package/dist/db.test.js.map +1 -1
- package/dist/diagram.d.ts +11 -0
- package/dist/diagram.js +232 -0
- package/dist/diagram.js.map +1 -0
- package/dist/diagram.test.d.ts +1 -0
- package/dist/diagram.test.js +52 -0
- package/dist/diagram.test.js.map +1 -0
- package/dist/duration.js +13 -5
- package/dist/duration.js.map +1 -1
- package/dist/effects.d.ts +6 -1
- package/dist/effects.js +97 -26
- package/dist/effects.js.map +1 -1
- package/dist/event-loop.d.ts +2 -3
- package/dist/event-loop.js +74 -44
- package/dist/event-loop.js.map +1 -1
- package/dist/format-parts.d.ts +21 -4
- package/dist/format-parts.js +40 -23
- package/dist/format-parts.js.map +1 -1
- package/dist/gateway-replay.e2e.test.js +4 -4
- package/dist/harness.e2e.test.js +2 -2
- package/dist/harness.e2e.test.js.map +1 -1
- package/dist/hello-thread.e2e.test.js +56 -5
- package/dist/hello-thread.e2e.test.js.map +1 -1
- package/dist/ingress.js +47 -12
- package/dist/ingress.js.map +1 -1
- package/dist/interaction-context.d.ts +4 -4
- package/dist/interaction-context.js +9 -7
- package/dist/interaction-context.js.map +1 -1
- package/dist/interrupt.e2e.test.js +8 -5
- package/dist/interrupt.e2e.test.js.map +1 -1
- package/dist/legacy.e2e.test.js +1 -1
- package/dist/lock-routes.d.ts +83 -31
- package/dist/lock-routes.js +68 -8
- package/dist/lock-routes.js.map +1 -1
- package/dist/lock-server.d.ts +3 -2
- package/dist/lock-server.js +23 -7
- package/dist/lock-server.js.map +1 -1
- package/dist/main.d.ts +6 -3
- package/dist/main.js +14 -4
- package/dist/main.js.map +1 -1
- package/dist/mainmast-idle.e2e.test.d.ts +1 -0
- package/dist/mainmast-idle.e2e.test.js +99 -0
- package/dist/mainmast-idle.e2e.test.js.map +1 -0
- package/dist/mainmast-queue.e2e.test.d.ts +1 -0
- package/dist/mainmast-queue.e2e.test.js +244 -0
- package/dist/mainmast-queue.e2e.test.js.map +1 -0
- package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
- package/dist/mainmast-warmup.e2e.test.js +130 -0
- package/dist/mainmast-warmup.e2e.test.js.map +1 -0
- package/dist/mainmast.d.ts +6 -0
- package/dist/mainmast.e2e.test.d.ts +1 -0
- package/dist/mainmast.e2e.test.js +428 -0
- package/dist/mainmast.e2e.test.js.map +1 -0
- package/dist/mainmast.js +156 -0
- package/dist/mainmast.js.map +1 -0
- package/dist/markdown/markdown.test.js +14 -4
- package/dist/markdown/markdown.test.js.map +1 -1
- package/dist/markdown/render-markdown.js +9 -6
- package/dist/markdown/render-markdown.js.map +1 -1
- package/dist/onboarding-cli.e2e.test.js +7 -6
- package/dist/onboarding-cli.e2e.test.js.map +1 -1
- package/dist/onboarding.d.ts +3 -10
- package/dist/onboarding.e2e.test.js +8 -10
- package/dist/onboarding.e2e.test.js.map +1 -1
- package/dist/onboarding.js +14 -24
- package/dist/onboarding.js.map +1 -1
- package/dist/permissions.e2e.test.js +3 -3
- package/dist/plugin/coordinator.d.ts +4 -0
- package/dist/plugin/coordinator.js +389 -0
- package/dist/plugin/coordinator.js.map +1 -0
- package/dist/plugin/index.js +93 -61
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/mainmast.d.ts +4 -0
- package/dist/plugin/mainmast.js +379 -0
- package/dist/plugin/mainmast.js.map +1 -0
- package/dist/plugin.e2e.test.js +1 -1
- package/dist/preference-commands.e2e.test.js +103 -24
- package/dist/preference-commands.e2e.test.js.map +1 -1
- package/dist/project.d.ts +1 -1
- package/dist/prompt.d.ts +22 -9
- package/dist/prompt.js +64 -48
- package/dist/prompt.js.map +1 -1
- package/dist/questions.e2e.test.js +13 -13
- package/dist/queue-sources.e2e.test.d.ts +1 -0
- package/dist/queue-sources.e2e.test.js +168 -0
- package/dist/queue-sources.e2e.test.js.map +1 -0
- package/dist/queue.d.ts +9 -1
- package/dist/queue.e2e.test.js +48 -30
- package/dist/queue.e2e.test.js.map +1 -1
- package/dist/queue.js +97 -24
- package/dist/queue.js.map +1 -1
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +57 -0
- package/dist/queue.test.js.map +1 -0
- package/dist/remote-send.e2e.test.js +6 -6
- package/dist/remote-send.js +2 -1
- package/dist/remote-send.js.map +1 -1
- package/dist/renderer.e2e.test.js +7 -7
- package/dist/restart.e2e.test.js +2 -2
- package/dist/routines.e2e.test.js +2 -2
- package/dist/scheduler.d.ts +3 -3
- package/dist/scheduler.e2e.test.js +43 -20
- package/dist/scheduler.e2e.test.js.map +1 -1
- package/dist/scheduler.js +4 -2
- package/dist/scheduler.js.map +1 -1
- package/dist/schema-sql.d.ts +1 -1
- package/dist/schema-sql.js +1 -1
- package/dist/schema-sql.js.map +1 -1
- package/dist/schema.d.ts +152 -6
- package/dist/schema.js +12 -0
- package/dist/schema.js.map +1 -1
- package/dist/session-commands.e2e.test.js +36 -11
- package/dist/session-commands.e2e.test.js.map +1 -1
- package/dist/session-events.d.ts +18 -6
- package/dist/session-events.js +175 -27
- package/dist/session-events.js.map +1 -1
- package/dist/session-ops.e2e.test.js +63 -12
- package/dist/session-ops.e2e.test.js.map +1 -1
- package/dist/sessions.d.ts +44 -21
- package/dist/sessions.js +177 -65
- package/dist/sessions.js.map +1 -1
- package/dist/shell.e2e.test.js +4 -4
- package/dist/slash-commands.js +6 -4
- package/dist/slash-commands.js.map +1 -1
- package/dist/slash-commands.test.js +2 -0
- package/dist/slash-commands.test.js.map +1 -1
- package/dist/sleep.e2e.test.js +29 -14
- package/dist/sleep.e2e.test.js.map +1 -1
- package/dist/sleeps.d.ts +2 -1
- package/dist/sleeps.js +21 -3
- package/dist/sleeps.js.map +1 -1
- package/dist/store.d.ts +1 -0
- package/dist/store.js +3 -2
- package/dist/store.js.map +1 -1
- package/dist/system-prompt.d.ts +5 -15
- package/dist/system-prompt.js +180 -131
- package/dist/system-prompt.js.map +1 -1
- package/dist/test/generate-log-plugin/index.d.ts +3 -0
- package/dist/test/generate-log-plugin/index.js +15 -0
- package/dist/test/generate-log-plugin/index.js.map +1 -0
- package/dist/test/harness.d.ts +20 -2
- package/dist/test/harness.js +51 -11
- package/dist/test/harness.js.map +1 -1
- package/dist/test/replay.js +11 -8
- package/dist/test/replay.js.map +1 -1
- package/dist/thread-reducer.d.ts +29 -6
- package/dist/thread-reducer.fixtures.test.js +58 -51
- package/dist/thread-reducer.fixtures.test.js.map +1 -1
- package/dist/thread-reducer.js +109 -50
- package/dist/thread-reducer.js.map +1 -1
- package/dist/thread-reducer.test.js +190 -48
- package/dist/thread-reducer.test.js.map +1 -1
- package/dist/tunnel.d.ts +9 -0
- package/dist/tunnel.js +117 -28
- package/dist/tunnel.js.map +1 -1
- package/dist/tunnel.test.js +81 -5
- package/dist/tunnel.test.js.map +1 -1
- package/dist/voice-attachments.e2e.test.js +108 -18
- package/dist/voice-attachments.e2e.test.js.map +1 -1
- package/dist/voice-call.e2e.test.js +9 -9
- package/dist/voice-call.js +6 -6
- package/dist/voice-call.js.map +1 -1
- package/dist/voice-openai.e2e.test.js +59 -23
- package/dist/voice-openai.e2e.test.js.map +1 -1
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +72 -24
- package/dist/voice.js.map +1 -1
- package/dist/working-directory.e2e.test.js +11 -11
- package/dist/working-directory.e2e.test.js.map +1 -1
- package/package.json +11 -5
- package/skills/kimaki-tunnel/SKILL.md +3 -2
- package/src/agent-ui.e2e.test.ts +27 -13
- package/src/agent-ui.ts +2 -1
- package/src/analytics.e2e.test.ts +1 -1
- package/src/autostart.ts +55 -10
- package/src/bot.ts +36 -19
- package/src/btw.e2e.test.ts +3 -3
- package/src/builtin-skills.test.ts +73 -1
- package/src/builtin-skills.ts +33 -0
- package/src/catalog-refresh.e2e.test.ts +2 -2
- package/src/cli/bot.ts +30 -19
- package/src/cli/discord.ts +30 -6
- package/src/cli/schedule.ts +23 -9
- package/src/cli/session.ts +35 -12
- package/src/cli/shared.ts +43 -8
- package/src/cli-discord.e2e.test.ts +13 -8
- package/src/cli-lock.e2e.test.ts +88 -20
- package/src/cli-read.e2e.test.ts +17 -8
- package/src/cli-sessions.e2e.test.ts +4 -3
- package/src/cli.ts +2 -0
- package/src/commands/preference-commands.ts +38 -4
- package/src/commands/session-commands.ts +12 -7
- package/src/commands/thread-commands.ts +92 -24
- package/src/commands/worktree-commands.ts +10 -9
- package/src/commands.e2e.test.ts +25 -21
- package/src/coordinator-agents.e2e.test.ts +145 -0
- package/src/coordinator-idle.e2e.test.ts +122 -0
- package/src/coordinator-queue.e2e.test.ts +279 -0
- package/src/coordinator-warmup.e2e.test.ts +158 -0
- package/src/coordinator.e2e.test.ts +545 -0
- package/src/coordinator.ts +168 -0
- package/src/db.test.ts +1 -0
- package/src/diagram.test.ts +56 -0
- package/src/diagram.ts +243 -0
- package/src/duration.ts +13 -5
- package/src/effects.ts +99 -27
- package/src/event-loop.ts +71 -45
- package/src/format-parts.ts +53 -22
- package/src/gateway-replay.e2e.test.ts +4 -4
- package/src/harness.e2e.test.ts +2 -2
- package/src/hello-thread.e2e.test.ts +59 -5
- package/src/ingress.ts +44 -11
- package/src/interaction-context.ts +12 -11
- package/src/interrupt.e2e.test.ts +8 -5
- package/src/legacy.e2e.test.ts +1 -1
- package/src/lock-routes.ts +62 -8
- package/src/lock-server.ts +23 -7
- package/src/main.ts +26 -10
- package/src/markdown/markdown.test.ts +15 -4
- package/src/markdown/render-markdown.ts +9 -6
- package/src/onboarding-cli.e2e.test.ts +7 -6
- package/src/onboarding.e2e.test.ts +8 -10
- package/src/onboarding.ts +13 -29
- package/src/permissions.e2e.test.ts +3 -3
- package/src/plugin/coordinator.ts +401 -0
- package/src/plugin/index.ts +86 -59
- package/src/plugin.e2e.test.ts +1 -1
- package/src/preference-commands.e2e.test.ts +103 -23
- package/src/prompt.ts +87 -43
- package/src/questions.e2e.test.ts +13 -13
- package/src/queue-sources.e2e.test.ts +197 -0
- package/src/queue.e2e.test.ts +48 -30
- package/src/queue.test.ts +59 -0
- package/src/queue.ts +104 -24
- package/src/remote-send.e2e.test.ts +6 -6
- package/src/remote-send.ts +2 -1
- package/src/renderer.e2e.test.ts +7 -7
- package/src/restart.e2e.test.ts +2 -2
- package/src/routines.e2e.test.ts +2 -2
- package/src/scheduler.e2e.test.ts +45 -19
- package/src/scheduler.ts +4 -2
- package/src/schema-sql.ts +1 -1
- package/src/schema.ts +13 -0
- package/src/session-commands.e2e.test.ts +38 -11
- package/src/session-events.ts +168 -25
- package/src/session-ops.e2e.test.ts +64 -11
- package/src/sessions.ts +217 -67
- package/src/shell.e2e.test.ts +4 -4
- package/src/slash-commands.test.ts +2 -0
- package/src/slash-commands.ts +6 -4
- package/src/sleep.e2e.test.ts +30 -13
- package/src/sleeps.ts +20 -3
- package/src/store.ts +5 -2
- package/src/system-prompt.ts +190 -134
- package/src/test/generate-log-plugin/index.ts +16 -0
- package/src/test/harness.ts +77 -17
- package/src/test/replay.ts +12 -7
- package/src/thread-reducer.fixtures.test.ts +58 -51
- package/src/thread-reducer.test.ts +199 -49
- package/src/thread-reducer.ts +135 -66
- package/src/tunnel.test.ts +81 -5
- package/src/tunnel.ts +124 -25
- package/src/voice-attachments.e2e.test.ts +110 -18
- package/src/voice-call.e2e.test.ts +9 -9
- package/src/voice-call.ts +6 -6
- package/src/voice-openai.e2e.test.ts +64 -24
- package/src/voice.ts +93 -26
- package/src/working-directory.e2e.test.ts +11 -11
- package/src/message-tool.e2e.test.ts +0 -45
package/src/effects.ts
CHANGED
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
type SendableChannels,
|
|
26
26
|
} from 'discord.js'
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { DISCORD_MEDIA_URL, embedFlags, formatFooter, SILENT_MESSAGE_FLAGS, type UiMessage } from './format-parts.ts'
|
|
29
29
|
import { createLogger } from './logger.ts'
|
|
30
|
-
import {
|
|
30
|
+
import { echoPayloads } from './queue.ts'
|
|
31
|
+
import { markdownPayloads, segmentPayloads, type DiscordPayload } from './markdown/components.ts'
|
|
31
32
|
import { DISCORD_TEXT_LIMIT, renderMarkdown } from './markdown/render-markdown.ts'
|
|
32
33
|
import { DiscordError } from './errors.ts'
|
|
33
34
|
import type { Effect, UploadFile } from './thread-reducer.ts'
|
|
@@ -42,10 +43,45 @@ async function gitBranch(directory: string): Promise<string | null> {
|
|
|
42
43
|
return result?.stdout.trim() || null
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
async function
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
46
|
+
async function readDefaultBranch(directory: string): Promise<string | null> {
|
|
47
|
+
const git = (args: string[]) => execFileAsync('git', args, { cwd: directory, timeout: 5_000 }).then((r) => r.stdout.trim(), () => '')
|
|
48
|
+
const remoteHead = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
|
|
49
|
+
if (remoteHead) return remoteHead.replace(/^origin\//, '')
|
|
50
|
+
// No origin/HEAD (repo never cloned, or no remote): fall back to a local main or master.
|
|
51
|
+
const local = await git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/main', 'refs/heads/master'])
|
|
52
|
+
return local.split('\n')[0] || null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Directory -> default branch, read once per process: it almost never changes.
|
|
56
|
+
const defaultBranches = new Map<string, Promise<string | null>>()
|
|
57
|
+
|
|
58
|
+
function defaultBranch(directory: string): Promise<string | null> {
|
|
59
|
+
const cached = defaultBranches.get(directory)
|
|
60
|
+
if (cached) return cached
|
|
61
|
+
const lookup = readDefaultBranch(directory)
|
|
62
|
+
defaultBranches.set(directory, lookup)
|
|
63
|
+
return lookup
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Project folder and default agent of a channel, read when a footer is posted. null: not a project channel.
|
|
67
|
+
export type FooterDefaults = (channelId: string) => Promise<Error | null | { projectDirectory: string; defaultAgent: string | null }>
|
|
68
|
+
|
|
69
|
+
async function footerText({ effect, footerDefaults }: { effect: Extract<Effect, { type: 'footer' }>; footerDefaults: FooterDefaults }): Promise<string> {
|
|
70
|
+
const { channelId, directory, durationMs, contextPercent, model, agent } = effect
|
|
71
|
+
const [branch, mainBranch, defaults] = await Promise.all([gitBranch(directory), defaultBranch(directory), footerDefaults(channelId)])
|
|
72
|
+
if (defaults instanceof Error) logger.warn(`footer defaults of channel ${channelId} failed`, defaults)
|
|
73
|
+
const known = defaults instanceof Error ? null : defaults
|
|
74
|
+
return formatFooter({
|
|
75
|
+
directory,
|
|
76
|
+
projectDirectory: known?.projectDirectory ?? null,
|
|
77
|
+
branch,
|
|
78
|
+
defaultBranch: mainBranch,
|
|
79
|
+
durationMs,
|
|
80
|
+
contextPercent,
|
|
81
|
+
model,
|
|
82
|
+
agent,
|
|
83
|
+
defaultAgent: known?.defaultAgent ?? null,
|
|
84
|
+
})
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
// post() adds the notification flags to the payload's own flag.
|
|
@@ -119,6 +155,16 @@ function uploadMessage({ files, first, numbered }: { files: readonly UploadFile[
|
|
|
119
155
|
return { files: attachments, components, flags: MessageFlags.IsComponentsV2 | SILENT_MESSAGE_FLAGS }
|
|
120
156
|
}
|
|
121
157
|
|
|
158
|
+
// Components V2 messages come back with an empty `attachments` list: Discord
|
|
159
|
+
// resolves each attachment:// URL inside the components instead, in upload order.
|
|
160
|
+
function mediaUrls(components: readonly APIMessageTopLevelComponent[]): string[] {
|
|
161
|
+
return components.flatMap((component) => {
|
|
162
|
+
if (component.type === ComponentType.MediaGallery) return component.items.map((item) => item.media.url)
|
|
163
|
+
if (component.type === ComponentType.File) return [component.file.url]
|
|
164
|
+
return []
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
122
168
|
// Effects that will never run: an upload still waits for its answer.
|
|
123
169
|
function drop(effects: readonly Effect[]) {
|
|
124
170
|
for (const effect of effects) {
|
|
@@ -128,7 +174,7 @@ function drop(effects: readonly Effect[]) {
|
|
|
128
174
|
|
|
129
175
|
export type MarkViewed = (input: { sessionId: string; idle: number }) => Promise<Error | void>
|
|
130
176
|
|
|
131
|
-
export function createEffectsRunner({ discord, markViewed }: { discord: Client; markViewed: MarkViewed }) {
|
|
177
|
+
export function createEffectsRunner({ discord, markViewed, footerDefaults }: { discord: Client; markViewed: MarkViewed; footerDefaults: FooterDefaults }) {
|
|
132
178
|
const workers = new Map<string, ThreadWorker>()
|
|
133
179
|
// After stop(): no more sends or typing, even from effects already queued.
|
|
134
180
|
const lifecycle = { closed: false }
|
|
@@ -164,20 +210,23 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
164
210
|
thread.typing = null
|
|
165
211
|
}
|
|
166
212
|
|
|
167
|
-
|
|
213
|
+
// `mentions: false` for user text (prompt echoes): it must never ping.
|
|
214
|
+
async function post({ threadId, options, notify, mentions = true }: { threadId: string; options: PostOptions; notify: boolean; mentions?: boolean }) {
|
|
168
215
|
if (lifecycle.closed) return null
|
|
169
216
|
const channel = await sendableChannel(threadId)
|
|
170
217
|
if (!channel || lifecycle.closed) return null
|
|
171
|
-
const flags = (options.flags ?? 0) | (notify ?
|
|
172
|
-
|
|
218
|
+
const flags = (options.flags ?? 0) | embedFlags(options.content) | (notify ? 0 : MessageFlags.SuppressNotifications)
|
|
219
|
+
// Replies never ping their author: the footer is what notifies.
|
|
220
|
+
const sent = await channel.send({ ...options, flags, allowedMentions: { parse: mentions ? ['users'] : [], repliedUser: false } }).catch((e: Error) => e)
|
|
173
221
|
if (!(sent instanceof Error)) return sent.id
|
|
174
222
|
logger.error(`send failed in ${threadId}`, sent)
|
|
175
223
|
return null
|
|
176
224
|
}
|
|
177
225
|
|
|
178
226
|
// Unlike post(), the error goes back to the agent that uploaded.
|
|
179
|
-
async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error |
|
|
227
|
+
async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error | string[]> {
|
|
180
228
|
let first = 1
|
|
229
|
+
const urls: string[] = []
|
|
181
230
|
for (const batch of uploadBatches(files)) {
|
|
182
231
|
if (lifecycle.closed) return new DiscordError({ operation: 'upload (bot stopped)' })
|
|
183
232
|
const channel = await sendableChannel(threadId)
|
|
@@ -186,14 +235,15 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
186
235
|
.send({ ...uploadMessage({ files: batch, first, numbered: files.length > 1 }), allowedMentions: { parse: [] } })
|
|
187
236
|
.catch((cause: Error) => new DiscordError({ operation: `upload of ${batch.map((file) => file.name).join(', ')} (${cause.message})`, cause }))
|
|
188
237
|
if (sent instanceof Error) return sent
|
|
238
|
+
urls.push(...mediaUrls(sent.components.map((component) => component.toJSON())))
|
|
189
239
|
first += batch.length
|
|
190
240
|
}
|
|
191
|
-
return
|
|
241
|
+
return urls
|
|
192
242
|
}
|
|
193
243
|
|
|
194
244
|
async function edit({ channel, messageId, message }: { channel: SendableChannels; messageId: string; message: UiMessage }) {
|
|
195
245
|
const edited = await channel.messages
|
|
196
|
-
.edit(messageId, { content: message.content, components: [...message.components] })
|
|
246
|
+
.edit(messageId, { content: message.content, components: [...message.components], flags: DISCORD_MEDIA_URL.test(message.content) ? [] : MessageFlags.SuppressEmbeds })
|
|
197
247
|
.catch((e: Error) => e)
|
|
198
248
|
if (edited instanceof Error) logger.warn(`edit of ${messageId} failed`, edited)
|
|
199
249
|
}
|
|
@@ -228,15 +278,19 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
228
278
|
return
|
|
229
279
|
}
|
|
230
280
|
if (effect.type === 'edit') {
|
|
231
|
-
const ids = thread.prompts.get(effect.key)
|
|
281
|
+
const ids = thread.prompts.get(effect.key) ?? []
|
|
232
282
|
thread.prompts.delete(effect.key)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!channel || lifecycle.closed) return
|
|
283
|
+
const channel = ids.length > 0 ? await sendableChannel(threadId) : null
|
|
284
|
+
if (lifecycle.closed) return
|
|
236
285
|
for (const [index, messageId] of ids.entries()) {
|
|
237
286
|
const message = effect.messages[index] ?? effect.messages[effect.messages.length - 1]
|
|
238
|
-
if (message) await edit({ channel, messageId, message })
|
|
287
|
+
if (channel && message) await edit({ channel, messageId, message })
|
|
239
288
|
}
|
|
289
|
+
if (effect.reply === undefined) return
|
|
290
|
+
// A key from before a restart: the reply still marks the moment, without a reference.
|
|
291
|
+
const replyTo = effect.replyTo ?? ids[0]
|
|
292
|
+
await post({ threadId, options: { content: effect.reply, ...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }) }, notify: false })
|
|
293
|
+
await refreshTyping(threadId)
|
|
240
294
|
return
|
|
241
295
|
}
|
|
242
296
|
if (effect.type === 'show') {
|
|
@@ -251,6 +305,7 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
251
305
|
...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }),
|
|
252
306
|
},
|
|
253
307
|
notify: effect.notify,
|
|
308
|
+
mentions: !effect.echo,
|
|
254
309
|
})
|
|
255
310
|
if (id) ids.push(id)
|
|
256
311
|
}
|
|
@@ -258,8 +313,9 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
258
313
|
await refreshTyping(threadId)
|
|
259
314
|
return
|
|
260
315
|
}
|
|
261
|
-
for (const payload of markdownMessages(effect)) {
|
|
262
|
-
|
|
316
|
+
for (const [index, payload] of markdownMessages(effect).entries()) {
|
|
317
|
+
const replyTo = index === 0 ? effect.replyTo : null
|
|
318
|
+
await post({ threadId, options: { ...payload, ...(replyTo && { reply: { messageReference: replyTo, failIfNotExists: false } }) }, notify: false })
|
|
263
319
|
}
|
|
264
320
|
await refreshTyping(threadId)
|
|
265
321
|
}
|
|
@@ -270,13 +326,16 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
270
326
|
const thread = worker(threadId)
|
|
271
327
|
const generation = thread.generation
|
|
272
328
|
const live = () => thread.generation === generation
|
|
273
|
-
// A merged message notifies when any of its lines does.
|
|
274
|
-
|
|
329
|
+
// A merged message notifies when any of its lines does. Echoes and
|
|
330
|
+
// bot lines never merge, so an echo never pings and a bot line can.
|
|
331
|
+
// Lines with Discord media only merge with each other: their message
|
|
332
|
+
// shows embeds, so other links merged in would unfurl too.
|
|
333
|
+
let lines: { text: string; notify: boolean; mentions: boolean; media: boolean } | null = null
|
|
275
334
|
const flush = async () => {
|
|
276
335
|
const pending = lines
|
|
277
336
|
lines = null
|
|
278
337
|
if (pending === null || !live()) return
|
|
279
|
-
await post({ threadId, options: { content: pending.text }, notify: pending.notify })
|
|
338
|
+
await post({ threadId, options: { content: pending.text }, notify: pending.notify, mentions: pending.mentions })
|
|
280
339
|
await refreshTyping(threadId)
|
|
281
340
|
}
|
|
282
341
|
for (const [index, effect] of batch.entries()) {
|
|
@@ -287,15 +346,28 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
287
346
|
await runOne(threadId, effect)
|
|
288
347
|
continue
|
|
289
348
|
}
|
|
290
|
-
const text = effect.type === 'send' ? effect.text : await footerText(effect)
|
|
349
|
+
const text = effect.type === 'send' ? effect.text : await footerText({ effect, footerDefaults })
|
|
291
350
|
const notify = effect.notify === true
|
|
351
|
+
const echo = effect.type === 'send' && effect.echo === true
|
|
352
|
+
const mentions = !echo
|
|
353
|
+
// A line over the limit (a long prompt echo) is split like model markdown.
|
|
354
|
+
if (text.length > DISCORD_TEXT_LIMIT) {
|
|
355
|
+
await flush()
|
|
356
|
+
for (const payload of echo ? echoPayloads(text) : markdownPayloads(text)) {
|
|
357
|
+
if (!live()) return drop(batch.slice(index + 1))
|
|
358
|
+
await post({ threadId, options: payload, notify, mentions })
|
|
359
|
+
}
|
|
360
|
+
await refreshTyping(threadId)
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
const media = DISCORD_MEDIA_URL.test(text)
|
|
292
364
|
const joined: string | null = lines === null ? null : `${lines.text}\n${text}`
|
|
293
|
-
if (lines !== null && joined !== null && joined.length <= DISCORD_TEXT_LIMIT) {
|
|
294
|
-
lines = { text: joined, notify: lines.notify || notify }
|
|
365
|
+
if (lines !== null && joined !== null && lines.mentions === mentions && lines.media === media && joined.length <= DISCORD_TEXT_LIMIT) {
|
|
366
|
+
lines = { text: joined, notify: lines.notify || notify, mentions, media }
|
|
295
367
|
continue
|
|
296
368
|
}
|
|
297
369
|
await flush()
|
|
298
|
-
lines = { text, notify }
|
|
370
|
+
lines = { text, notify, mentions, media }
|
|
299
371
|
}
|
|
300
372
|
await flush()
|
|
301
373
|
}
|
package/src/event-loop.ts
CHANGED
|
@@ -14,24 +14,26 @@
|
|
|
14
14
|
// cold thread a bound thread without a view yet (bound in an earlier
|
|
15
15
|
// bot run): read its project channel and directory once.
|
|
16
16
|
|
|
17
|
-
import { DiscordAPIError, RESTJSONErrorCodes, type Client } from 'discord.js'
|
|
17
|
+
import { ChannelType, DiscordAPIError, RESTJSONErrorCodes, type Client } from 'discord.js'
|
|
18
18
|
|
|
19
19
|
import type { Analytics } from './analytics.ts'
|
|
20
20
|
import { verbosityFromV1, type KimakiDb } from './db.ts'
|
|
21
21
|
import type { EffectsRunner } from './effects.ts'
|
|
22
22
|
import { DbError, DiscordError, OpenCodeError } from './errors.ts'
|
|
23
|
-
import { formatDuration } from './format-parts.ts'
|
|
24
23
|
import { createLogger } from './logger.ts'
|
|
25
24
|
import type { ConnectContext, OpenCodeClient, V2Event } from './opencode-server.ts'
|
|
26
|
-
import type
|
|
25
|
+
import { formatDuration, type EventRecorder } from './session-events.ts'
|
|
27
26
|
import { queuedItems } from './queue.ts'
|
|
28
|
-
import type { BotStore } from './store.ts'
|
|
27
|
+
import type { BotStore, State } from './store.ts'
|
|
29
28
|
import {
|
|
29
|
+
conversationTyping,
|
|
30
30
|
emptyView,
|
|
31
31
|
eventSessionId,
|
|
32
32
|
isKimakiEvent,
|
|
33
33
|
reduce,
|
|
34
|
+
type Effect,
|
|
34
35
|
type KimakiEvent,
|
|
36
|
+
type Prefs,
|
|
35
37
|
type SessionSnapshot,
|
|
36
38
|
type ThreadEvent,
|
|
37
39
|
} from './thread-reducer.ts'
|
|
@@ -67,7 +69,6 @@ export function createEventLoop({
|
|
|
67
69
|
effects,
|
|
68
70
|
recorder,
|
|
69
71
|
analytics,
|
|
70
|
-
messageTool,
|
|
71
72
|
onRunEnded,
|
|
72
73
|
onTranscriptDue,
|
|
73
74
|
}: {
|
|
@@ -77,8 +78,6 @@ export function createEventLoop({
|
|
|
77
78
|
effects: EffectsRunner
|
|
78
79
|
recorder: EventRecorder
|
|
79
80
|
analytics: Analytics
|
|
80
|
-
// `kimaki --message-tool`: plain text of root sessions stays hidden (Prefs.messageTool).
|
|
81
|
-
messageTool: boolean
|
|
82
81
|
// Every finished or failed run of a thread's root session (voice call notifications).
|
|
83
82
|
onRunEnded: (run: RunEnded) => void
|
|
84
83
|
// A root session stopped running (also after an interrupt): its transcript file is stale.
|
|
@@ -86,49 +85,66 @@ export function createEventLoop({
|
|
|
86
85
|
}) {
|
|
87
86
|
// The client of the current connection, set before hydration starts.
|
|
88
87
|
const connection: { client: OpenCodeClient | null } = { client: null }
|
|
89
|
-
//
|
|
88
|
+
// Model names and context window sizes ("providerID/modelID" -> info) by directory.
|
|
90
89
|
// Cleared on connect; reloaded when the catalog changes (a /login adds a
|
|
91
|
-
// provider). `
|
|
92
|
-
// old
|
|
93
|
-
const
|
|
90
|
+
// provider). `models` is null while the first load runs; a reload keeps the
|
|
91
|
+
// old models until it finishes. Failed loads are removed so the next call retries.
|
|
92
|
+
const modelCatalog = new Map<string, { load: Promise<void>; models: Prefs['models'] | null }>()
|
|
94
93
|
const coldThreads = new Map<string, Held>()
|
|
95
94
|
const unknownSessions = new Map<string, Held>()
|
|
96
95
|
const ignoredSessions = new Set<string>()
|
|
97
96
|
|
|
98
|
-
// Resolves when the
|
|
97
|
+
// Resolves when the models of `directory` are known (or failed to load).
|
|
99
98
|
// `reload`: read them again even when cached or loading.
|
|
100
|
-
function
|
|
99
|
+
function loadModels(directory: string, { reload = false } = {}): Promise<void> {
|
|
101
100
|
const client = connection.client
|
|
102
101
|
if (!client) return Promise.resolve()
|
|
103
|
-
const existing =
|
|
102
|
+
const existing = modelCatalog.get(directory)
|
|
104
103
|
if (existing && !reload) return existing.load
|
|
105
104
|
const models = client.model.list({ location: { directory } }).catch((e) => new OpenCodeError({ operation: 'model.list', cause: e }))
|
|
106
105
|
const load = models.then((result) => {
|
|
107
106
|
// A reconnect or a newer reload replaced this load: its result is stale.
|
|
108
|
-
if (
|
|
107
|
+
if (modelCatalog.get(directory)?.load !== load) return
|
|
109
108
|
if (result instanceof Error) {
|
|
110
|
-
|
|
109
|
+
modelCatalog.delete(directory)
|
|
111
110
|
logger.warn(result)
|
|
112
111
|
return
|
|
113
112
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
113
|
+
const models = Object.fromEntries(result.data.map((model) => [`${model.providerID}/${model.id}`, { name: model.name, context: model.limit.context }]))
|
|
114
|
+
modelCatalog.set(directory, { load, models })
|
|
116
115
|
})
|
|
117
|
-
|
|
116
|
+
modelCatalog.set(directory, { load, models: existing?.models ?? null })
|
|
118
117
|
return load
|
|
119
118
|
}
|
|
120
119
|
|
|
120
|
+
// Every change of thread views goes through here. The reducer's typing only
|
|
121
|
+
// sees its own view; typing follows conversationTyping instead, which also
|
|
122
|
+
// changes in the coordinator of the thread's channel.
|
|
123
|
+
function commit({ threadId, effects: produced = [], update }: { threadId: string; effects?: readonly Effect[]; update: (state: State) => Partial<State> }) {
|
|
124
|
+
const before = store.getState().threads
|
|
125
|
+
store.setState(update)
|
|
126
|
+
const after = store.getState().threads
|
|
127
|
+
const typing = (id: string): Effect[] => {
|
|
128
|
+
const on = conversationTyping({ threads: after, threadId: id })
|
|
129
|
+
return conversationTyping({ threads: before, threadId: id }) === on ? [] : [{ type: 'typing', on }]
|
|
130
|
+
}
|
|
131
|
+
// A removed view: effects.dispose() already stopped its typing.
|
|
132
|
+
if (after[threadId]) effects.run(threadId, [...typing(threadId), ...produced.filter((effect) => effect.type !== 'typing')])
|
|
133
|
+
const channelId = (after[threadId] ?? before[threadId])?.channelId
|
|
134
|
+
if (channelId && channelId !== threadId) effects.run(channelId, typing(channelId))
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
// Folds one event into a loaded view.
|
|
122
138
|
function fold(threadId: string, event: ThreadEvent) {
|
|
123
139
|
const state = store.getState()
|
|
124
140
|
const view = state.threads[threadId]
|
|
125
141
|
if (!view) return
|
|
126
142
|
const verbosity = state.verbosity[view.channelId] ?? verbosityFromV1(null)
|
|
127
|
-
const
|
|
128
|
-
const result = reduce({ view, event, prefs: { verbosity,
|
|
129
|
-
if (result.view
|
|
130
|
-
|
|
131
|
-
if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void
|
|
143
|
+
const models = modelCatalog.get(view.directory)?.models ?? {}
|
|
144
|
+
const result = reduce({ view, event, prefs: { verbosity, models } })
|
|
145
|
+
if (result.view === view) effects.run(threadId, result.effects)
|
|
146
|
+
else commit({ threadId, effects: result.effects, update: (current) => ({ threads: { ...current.threads, [threadId]: result.view } }) })
|
|
147
|
+
if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void loadModels(event.data.location.directory)
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
// One line per run start and end of a root session, so the log shows what each thread did.
|
|
@@ -187,7 +203,8 @@ export function createEventLoop({
|
|
|
187
203
|
if (!sessionId) return null
|
|
188
204
|
const thread = await discord.channels.fetch(threadId).catch((e) => e instanceof DiscordAPIError && e.code === RESTJSONErrorCodes.UnknownChannel ? null : new DiscordError({ operation: `fetch thread ${threadId}`, cause: e }))
|
|
189
205
|
if (thread instanceof Error) return thread
|
|
190
|
-
|
|
206
|
+
// A coordinator channel is its own conversation.
|
|
207
|
+
const channelId = thread?.isThread() ? thread.parentId : thread?.type === ChannelType.GuildText ? thread.id : null
|
|
191
208
|
if (!channelId) return null
|
|
192
209
|
const project = await db.query.channel_directories.findFirst({ where: { channel_id: channelId } }).catch((e) => new DbError({ operation: 'read channel_directories', cause: e }))
|
|
193
210
|
if (project instanceof Error) return project
|
|
@@ -196,7 +213,7 @@ export function createEventLoop({
|
|
|
196
213
|
if (!client) return new OpenCodeError({ operation: 'load session directory while disconnected' })
|
|
197
214
|
const info = await client.session.get({ sessionID: sessionId }).catch((cause) => new OpenCodeError({ operation: 'read session directory', cause }))
|
|
198
215
|
if (info instanceof Error) return info
|
|
199
|
-
await
|
|
216
|
+
await loadModels(info.location.directory)
|
|
200
217
|
return { channelId, directory: info.location.directory }
|
|
201
218
|
})()
|
|
202
219
|
entry.loading = false
|
|
@@ -214,8 +231,8 @@ export function createEventLoop({
|
|
|
214
231
|
return
|
|
215
232
|
}
|
|
216
233
|
if (!store.getState().threads[threadId]) {
|
|
217
|
-
const view = emptyView({ sessionId, channelId: loaded.channelId, directory: loaded.directory, isNew: false })
|
|
218
|
-
|
|
234
|
+
const view = emptyView({ sessionId, channelId: loaded.channelId, directory: loaded.directory, isNew: false, coordinator: threadId === loaded.channelId })
|
|
235
|
+
commit({ threadId, update: (current) => ({ threads: { ...current.threads, [threadId]: view } }) })
|
|
219
236
|
}
|
|
220
237
|
for (const event of entry.events) fold(threadId, event)
|
|
221
238
|
}
|
|
@@ -321,7 +338,7 @@ export function createEventLoop({
|
|
|
321
338
|
const failed = sessions.find((session) => session instanceof Error)
|
|
322
339
|
if (failed instanceof Error) return failed
|
|
323
340
|
const directory = info?.location.directory ?? null
|
|
324
|
-
if (directory) await
|
|
341
|
+
if (directory) await loadModels(directory)
|
|
325
342
|
const event: ThreadSnapshot['event'] = {
|
|
326
343
|
type: 'kimaki.snapshot',
|
|
327
344
|
at,
|
|
@@ -351,19 +368,22 @@ export function createEventLoop({
|
|
|
351
368
|
}
|
|
352
369
|
|
|
353
370
|
return {
|
|
354
|
-
// Bindings
|
|
371
|
+
// Bindings, channel verbosity and coordinator mode from SQLite. Several bindings can share a
|
|
355
372
|
// session after V1 /resume; the most recently updated one wins.
|
|
356
373
|
async load(): Promise<DbError | void> {
|
|
357
|
-
const [rows, verbosity] = await Promise.all([
|
|
374
|
+
const [rows, verbosity, coordinator] = await Promise.all([
|
|
358
375
|
db.query.thread_sessions.findMany({ orderBy: { updated_at: 'asc' } }).catch((e) => new DbError({ operation: 'read thread_sessions', cause: e })),
|
|
359
376
|
db.query.channel_verbosity.findMany().catch((e) => new DbError({ operation: 'read channel_verbosity', cause: e })),
|
|
377
|
+
db.query.channel_coordinator.findMany().catch((e) => new DbError({ operation: 'read channel_coordinator', cause: e })),
|
|
360
378
|
])
|
|
361
379
|
if (rows instanceof Error) return rows
|
|
362
380
|
if (verbosity instanceof Error) return verbosity
|
|
381
|
+
if (coordinator instanceof Error) return coordinator
|
|
363
382
|
store.setState({
|
|
364
383
|
roots: Object.fromEntries(rows.map((row) => [row.thread_id, row.session_id])),
|
|
365
384
|
sessionThreads: Object.fromEntries(rows.map((row) => [row.session_id, row.thread_id])),
|
|
366
385
|
verbosity: Object.fromEntries(verbosity.map((row) => [row.channel_id, verbosityFromV1(row.verbosity)])),
|
|
386
|
+
coordinator: Object.fromEntries(coordinator.map((row) => [row.channel_id, row.enabled === 1])),
|
|
367
387
|
})
|
|
368
388
|
},
|
|
369
389
|
|
|
@@ -388,26 +408,32 @@ export function createEventLoop({
|
|
|
388
408
|
}): Promise<void> {
|
|
389
409
|
ignoredSessions.delete(sessionId)
|
|
390
410
|
coldThreads.delete(threadId)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
411
|
+
commit({
|
|
412
|
+
threadId,
|
|
413
|
+
update: (current) => ({
|
|
414
|
+
roots: { ...current.roots, [threadId]: sessionId },
|
|
415
|
+
sessionThreads: { ...current.sessionThreads, [sessionId]: threadId },
|
|
416
|
+
threads: { ...current.threads, [threadId]: emptyView({ sessionId, channelId, directory, isNew, coordinator: threadId === channelId }) },
|
|
417
|
+
}),
|
|
418
|
+
})
|
|
396
419
|
// Same synchronous turn as the routing change: no live event can come first.
|
|
397
420
|
for (const event of first) deliver(threadId, event)
|
|
398
421
|
// Before the first prompt, so its footer can show the context percent.
|
|
399
|
-
await
|
|
422
|
+
await loadModels(directory)
|
|
400
423
|
},
|
|
401
424
|
|
|
402
425
|
// A thread loses its session (`/resume` moved the session to a new thread).
|
|
403
426
|
unbind(threadId: string): void {
|
|
404
427
|
coldThreads.delete(threadId)
|
|
405
428
|
effects.dispose(threadId)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
429
|
+
commit({
|
|
430
|
+
threadId,
|
|
431
|
+
update: (current) => {
|
|
432
|
+
const { [threadId]: _root, ...roots } = current.roots
|
|
433
|
+
const { [threadId]: _view, ...threads } = current.threads
|
|
434
|
+
const sessionThreads = Object.fromEntries(Object.entries(current.sessionThreads).filter(([, mapped]) => mapped !== threadId))
|
|
435
|
+
return { roots, threads, sessionThreads }
|
|
436
|
+
},
|
|
411
437
|
})
|
|
412
438
|
},
|
|
413
439
|
|
|
@@ -429,7 +455,7 @@ export function createEventLoop({
|
|
|
429
455
|
async onConnect({ client, signal }: ConnectContext): Promise<OpenCodeError | void> {
|
|
430
456
|
connection.client = client
|
|
431
457
|
// A new connection may bring new providers, and old unrelated sessions are gone.
|
|
432
|
-
|
|
458
|
+
modelCatalog.clear()
|
|
433
459
|
ignoredSessions.clear()
|
|
434
460
|
const result = await snapshots({ client, signal })
|
|
435
461
|
if (result instanceof Error) return result
|
|
@@ -444,9 +470,9 @@ export function createEventLoop({
|
|
|
444
470
|
onEvent(event: V2Event): void {
|
|
445
471
|
if (CATALOG_EVENTS.has(event.type)) {
|
|
446
472
|
// Credential events have no location: they can change every directory.
|
|
447
|
-
const directories = event.location ? [event.location.directory] : [...
|
|
473
|
+
const directories = event.location ? [event.location.directory] : [...modelCatalog.keys()]
|
|
448
474
|
for (const directory of directories) {
|
|
449
|
-
if (
|
|
475
|
+
if (modelCatalog.has(directory)) void loadModels(directory, { reload: true })
|
|
450
476
|
}
|
|
451
477
|
return
|
|
452
478
|
}
|
package/src/format-parts.ts
CHANGED
|
@@ -16,12 +16,24 @@ import {
|
|
|
16
16
|
import { z } from 'zod'
|
|
17
17
|
|
|
18
18
|
import type { Verbosity } from './db.ts'
|
|
19
|
+
import { formatDuration, formatTokens } from './session-events.ts'
|
|
19
20
|
|
|
20
|
-
// Bot messages
|
|
21
|
+
// Bot messages hide link previews (session output: see embedFlags). Only messages that need the user
|
|
21
22
|
// (errors, prompts, the end of a turn) skip SuppressNotifications.
|
|
22
23
|
export const SILENT_MESSAGE_FLAGS = MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications
|
|
23
24
|
export const NOTIFY_MESSAGE_FLAGS = MessageFlags.SuppressEmbeds
|
|
24
25
|
|
|
26
|
+
// Discord CDN links (attachments from other threads, avatars, emojis) only
|
|
27
|
+
// show the media through their embed. Signed attachment URLs (?ex=&is=&hm=)
|
|
28
|
+
// are refreshed by the Discord client, so old ones still render.
|
|
29
|
+
// https://docs.discord.com/developers/reference#signed-attachment-cdn-urls
|
|
30
|
+
export const DISCORD_MEDIA_URL = /https:\/\/(?:cdn\.discordapp\.com|media\.discordapp\.net)\/\S/
|
|
31
|
+
|
|
32
|
+
// Session output hides link previews, except in messages with Discord media.
|
|
33
|
+
export function embedFlags(content: string | undefined): MessageFlags.SuppressEmbeds | 0 {
|
|
34
|
+
return content && DISCORD_MEDIA_URL.test(content) ? 0 : MessageFlags.SuppressEmbeds
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export function asSubtext(text: string): string {
|
|
26
38
|
return `-# ${text}`
|
|
27
39
|
}
|
|
@@ -108,45 +120,59 @@ export function selectedPage(value: string): number | null {
|
|
|
108
120
|
return Number(value.slice(PAGE_PREFIX.length)) || 0
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
export function formatDuration(ms: number): string {
|
|
112
|
-
if (ms < 1_000) return '<1s'
|
|
113
|
-
const seconds = Math.floor(ms / 1_000)
|
|
114
|
-
if (seconds < 60) return `${seconds}s`
|
|
115
|
-
const minutes = Math.floor(seconds / 60)
|
|
116
|
-
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
|
117
|
-
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
118
|
-
}
|
|
119
|
-
|
|
120
123
|
export type ModelRef = { providerID: string; id: string }
|
|
121
124
|
|
|
122
|
-
|
|
125
|
+
const COORDINATOR_NOTICE = 'Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with `--coordinator false`.'
|
|
126
|
+
|
|
127
|
+
export function formatBanner({ model, agent, coordinator = false }: { model: ModelRef & { variant?: string }; agent: string; coordinator?: boolean }): string {
|
|
123
128
|
// OpenCode reports `default` when no variant is selected.
|
|
124
129
|
const variant = model.variant && model.variant !== 'default' ? ` ${model.variant}` : ''
|
|
125
|
-
|
|
130
|
+
const banner = asSubtext(`*using ${model.providerID}/${model.id}${variant} ⋅ ${agent}*`)
|
|
131
|
+
return coordinator ? `${banner}\n${asSubtext(COORDINATOR_NOTICE)}` : banner
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
// "Switched model to Claude Opus 5.5 (high)", for a root session switch after its banner.
|
|
135
|
+
export function formatModelSwitched({ name, variant }: { name: string; variant?: string }): string {
|
|
136
|
+
return asSubtext(`Switched model to ${name}${variant && variant !== 'default' ? ` (${variant})` : ''}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const agentLabel = (agent: string) => agent.charAt(0).toUpperCase() + agent.slice(1)
|
|
140
|
+
|
|
141
|
+
export function formatAgentSwitched({ agent, previous }: { agent: string; previous?: string }): string {
|
|
142
|
+
return asSubtext(`Switched agent ${previous ? `from ${agentLabel(previous)} ` : ''}to ${agentLabel(agent)}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The folder, branch and agent only show when they differ from the defaults.
|
|
146
|
+
// `projectDirectory` / `defaultBranch` / `defaultAgent`: null when unknown, then they show.
|
|
128
147
|
export function formatFooter({
|
|
129
|
-
|
|
148
|
+
directory,
|
|
149
|
+
projectDirectory,
|
|
130
150
|
branch,
|
|
151
|
+
defaultBranch,
|
|
131
152
|
durationMs,
|
|
132
153
|
contextPercent,
|
|
133
154
|
model,
|
|
134
155
|
agent,
|
|
156
|
+
defaultAgent,
|
|
135
157
|
}: {
|
|
136
|
-
|
|
158
|
+
directory: string
|
|
159
|
+
projectDirectory: string | null
|
|
137
160
|
branch: string | null
|
|
161
|
+
defaultBranch: string | null
|
|
138
162
|
durationMs: number
|
|
139
163
|
contextPercent: number | null
|
|
140
164
|
model: ModelRef | null
|
|
141
165
|
agent: string | null
|
|
166
|
+
defaultAgent: string | null
|
|
142
167
|
}): string {
|
|
168
|
+
const inProject = projectDirectory !== null && path.resolve(directory) === path.resolve(projectDirectory)
|
|
143
169
|
const parts = [
|
|
144
|
-
|
|
145
|
-
branch,
|
|
170
|
+
inProject ? null : path.basename(directory),
|
|
171
|
+
branch === defaultBranch ? null : branch,
|
|
146
172
|
formatDuration(durationMs),
|
|
147
173
|
contextPercent === null ? null : `${contextPercent}%`,
|
|
148
174
|
model?.id ?? null,
|
|
149
|
-
agent && agent !==
|
|
175
|
+
agent && agent.toLowerCase() !== defaultAgent?.toLowerCase() ? agent : null,
|
|
150
176
|
].filter((part): part is string => Boolean(part))
|
|
151
177
|
return asSubtext(`*${parts.join(' ⋅ ')}*`)
|
|
152
178
|
}
|
|
@@ -162,12 +188,17 @@ export function formatError(message: string): string {
|
|
|
162
188
|
// The provider error is the whole point: "retrying" alone says nothing.
|
|
163
189
|
export function formatRetry({ attempt, delayMs, message }: { attempt: number; delayMs: number; message: string }): string {
|
|
164
190
|
const seconds = Math.max(1, Math.ceil(delayMs / 1_000))
|
|
165
|
-
return asSubtext(
|
|
191
|
+
return asSubtext(`retrying in ${seconds}s (attempt ${attempt}): ${inline(message, 200)}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// `tokens`: prompt size of the first step after the compaction, null when no step ran yet (`/compact`).
|
|
195
|
+
export function formatCompacted({ tokens, percent }: { tokens: number | null; percent: number | null }): string {
|
|
196
|
+
if (tokens === null) return asSubtext('context compacted')
|
|
197
|
+
return asSubtext(`context compacted to ${formatTokens(tokens)} tokens${percent === null ? '' : ` (${percent}%)`}`)
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
export function formatCacheMiss({ read, expected, idleMs }: { read: number; expected: number; idleMs: number }): string {
|
|
169
|
-
|
|
170
|
-
return asSubtext(`⬦ prompt cache miss: ${k(read)} of ${k(expected)} tokens cached, ${formatDuration(idleMs)} after the last request`)
|
|
201
|
+
return asSubtext(`prompt cache miss: ${formatTokens(read)} of ${formatTokens(expected)} tokens cached, ${formatDuration(idleMs)} after the last request`)
|
|
171
202
|
}
|
|
172
203
|
|
|
173
204
|
// One line, no markdown control characters, so `_x_` and `*x*` stay intact.
|
|
@@ -350,7 +381,7 @@ export function formatToolFailed({ name, message, label }: { name: string; messa
|
|
|
350
381
|
}
|
|
351
382
|
|
|
352
383
|
export function formatSubagentFinished({ agent, description }: { agent: string; description: string }): string {
|
|
353
|
-
return asSubtext(
|
|
384
|
+
return asSubtext(`${inline(agent)} finished: ${inline(description)}`)
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
// `state` is OpenCode's job state; any value other than "completed" is shown.
|
|
@@ -370,7 +401,7 @@ export function formatShellFinished({
|
|
|
370
401
|
state && state !== 'completed' ? inline(state) : null,
|
|
371
402
|
exit !== null && exit !== 0 ? `exit ${exit}` : null,
|
|
372
403
|
].filter((note) => note !== null)
|
|
373
|
-
return asSubtext(
|
|
404
|
+
return asSubtext(`${prefix}background shell finished${description ? `: _${inline(description)}_` : ''}${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`)
|
|
374
405
|
}
|
|
375
406
|
|
|
376
407
|
const SHELL_OUTPUT_LIMIT = 1_800
|