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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Coordinator notes: when a thread session finishes a run, its coordinator learns
|
|
2
|
+
// about it, so the coordinator always knows what happened in its project.
|
|
3
|
+
//
|
|
4
|
+
// thread run ends ─▶ `kimaki send --wait` prompt, wait not over, target busy? ─▶ no note: the wait prints the answer
|
|
5
|
+
// ├─▶ run prompt sent by a coordinator (`sender`)? ─▶ note + wake that coordinator
|
|
6
|
+
// └─▶ else (user steered, routine), channel coordinator? ─▶ silent note (read at next prompt)
|
|
7
|
+
//
|
|
8
|
+
// Who sent the prompt is in its metadata (`sender`, prompt.ts), read from the
|
|
9
|
+
// run's messages: no state of its own. The note is a synthetic inbox item, like
|
|
10
|
+
// OpenCode's own subagent completion (core/src/session/subagent-completion.ts):
|
|
11
|
+
// `resume` decides if it wakes the session.
|
|
12
|
+
|
|
13
|
+
import { isCoordinatorChannel, oc, projectOf, readMarker, type Bot } from './bot.ts'
|
|
14
|
+
import type { RunEnded } from './event-loop.ts'
|
|
15
|
+
import { createLogger } from './logger.ts'
|
|
16
|
+
import { IDLE_REPLY } from './plugin/coordinator.ts'
|
|
17
|
+
import type { Sender } from './prompt.ts'
|
|
18
|
+
import { finalAnswer, formatDuration, formatTokens, sessionUsage, type Message } from './session-events.ts'
|
|
19
|
+
import { escapeAttribute } from './system-prompt.ts'
|
|
20
|
+
|
|
21
|
+
const logger = createLogger('COORDINATOR')
|
|
22
|
+
|
|
23
|
+
// A woken coordinator reads the whole answer; a silent note only says what happened.
|
|
24
|
+
const WAKE_ANSWER_CHARS = 2_000
|
|
25
|
+
const NOTE_ANSWER_CHARS = 400
|
|
26
|
+
const USER_PROMPT_CHARS = 1_000
|
|
27
|
+
const RUN_PAGE_MESSAGES = 30
|
|
28
|
+
// A run longer than this only loses uploads from its first steps.
|
|
29
|
+
const MAX_RUN_PAGES = 10
|
|
30
|
+
|
|
31
|
+
// The messages of the last run, oldest first: pages back until its user prompt.
|
|
32
|
+
async function lastRunMessages(bot: Bot, sessionId: string) {
|
|
33
|
+
const messages: Message[] = []
|
|
34
|
+
let cursor: string | undefined
|
|
35
|
+
for (let page = 0; page < MAX_RUN_PAGES; page++) {
|
|
36
|
+
const result = await oc(bot, 'message.list', (client) =>
|
|
37
|
+
client.message.list({ sessionID: sessionId, limit: RUN_PAGE_MESSAGES, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
|
|
38
|
+
)
|
|
39
|
+
if (result instanceof Error) return result
|
|
40
|
+
messages.push(...result.data)
|
|
41
|
+
cursor = result.cursor.next ?? undefined
|
|
42
|
+
if (!cursor || result.data.some((message) => message.type === 'user')) break
|
|
43
|
+
}
|
|
44
|
+
return messages.reverse()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Who wrote the run's prompt, from its metadata (prompt.ts): a sender
|
|
48
|
+
// (`kimaki send`, routine, sleep wake) or a person in Discord.
|
|
49
|
+
function runPrompt(messages: readonly Message[]): { sender: Sender | null; user: { username: string; text: string } | null } {
|
|
50
|
+
const prompt = messages.findLast((message) => message.type === 'user')
|
|
51
|
+
if (prompt?.type !== 'user') return { sender: null, user: null }
|
|
52
|
+
const sender: unknown = prompt.metadata?.['sender']
|
|
53
|
+
if (sender && typeof sender === 'object') {
|
|
54
|
+
const sessionId: unknown = Reflect.get(sender, 'sessionId')
|
|
55
|
+
const waitUntil: unknown = Reflect.get(sender, 'waitUntil')
|
|
56
|
+
return { sender: { ...(typeof sessionId === 'string' && { sessionId }), ...(typeof waitUntil === 'number' && { waitUntil }) }, user: null }
|
|
57
|
+
}
|
|
58
|
+
const discord: unknown = prompt.metadata?.['discord']
|
|
59
|
+
const username: unknown = discord && typeof discord === 'object' ? Reflect.get(discord, 'username') : null
|
|
60
|
+
return { sender: null, user: typeof username === 'string' ? { username, text: prompt.text } : null }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The coordinator a session belongs to: the root of its conversation when that is
|
|
64
|
+
// a coordinator channel. The sender may be a subagent of that coordinator.
|
|
65
|
+
async function coordinatorOf(bot: Bot, sessionId: string): Promise<string | null> {
|
|
66
|
+
const { sessionThreads, roots } = bot.store.getState()
|
|
67
|
+
const conversation = sessionThreads[sessionId]
|
|
68
|
+
if (!conversation || !(await holdsCoordinator(bot, conversation))) return null
|
|
69
|
+
return roots[conversation] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cut(text: string, max: number): string {
|
|
73
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A conversation that is a project channel in coordinator mode holds a coordinator.
|
|
77
|
+
async function holdsCoordinator(bot: Bot, conversationId: string): Promise<boolean> {
|
|
78
|
+
if (!isCoordinatorChannel(bot, conversationId)) return false
|
|
79
|
+
const project = await projectOf(bot, conversationId)
|
|
80
|
+
return !(project instanceof Error) && project !== null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The project channel of a session thread, from its view or from Discord.
|
|
84
|
+
async function channelOfThread(bot: Bot, threadId: string): Promise<string | null> {
|
|
85
|
+
const view = bot.store.getState().threads[threadId]
|
|
86
|
+
if (view) return view.channelId
|
|
87
|
+
const channel = await bot.discord.channels.fetch(threadId).catch(() => null)
|
|
88
|
+
return channel?.isThread() ? channel.parentId : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The run's prompt came from `kimaki send --wait`, the run ended before the wait
|
|
92
|
+
// ran out and the target still runs: it is waiting in that shell call, which
|
|
93
|
+
// prints the answer. A target that is idle lost its wait (shell timeout,
|
|
94
|
+
// interrupt), so it gets the note.
|
|
95
|
+
async function isWaiting(bot: Bot, target: string): Promise<boolean> {
|
|
96
|
+
const active = await oc(bot, 'session.active', (client) => client.session.active())
|
|
97
|
+
// On a failed check the note goes out: a duplicate is better than a lost result.
|
|
98
|
+
if (active instanceof Error) {
|
|
99
|
+
logger.warn(`wait check of ${target}`, active)
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
return target in active
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function noteRunEnded(bot: Bot, run: RunEnded): Promise<void> {
|
|
106
|
+
const channelId = await channelOfThread(bot, run.threadId)
|
|
107
|
+
// The coordinator's own runs, and threads outside any project channel.
|
|
108
|
+
if (!channelId || channelId === run.threadId) return
|
|
109
|
+
const [info, messages] = await Promise.all([
|
|
110
|
+
oc(bot, 'session.get', (client) => client.session.get({ sessionID: run.sessionId })),
|
|
111
|
+
lastRunMessages(bot, run.sessionId),
|
|
112
|
+
])
|
|
113
|
+
if (info instanceof Error) return logger.warn(`note of ${run.sessionId}`, info)
|
|
114
|
+
if (messages instanceof Error) return logger.warn(`answer of ${run.sessionId}`, messages)
|
|
115
|
+
const marker = readMarker(info.metadata)
|
|
116
|
+
const { sender, user } = runPrompt(messages)
|
|
117
|
+
// TODO: also note sessions on other machines (remote `kimaki send`, OpenCode workspaces).
|
|
118
|
+
// Their bot cannot reach this OpenCode server: relay the note through a Discord envelope
|
|
119
|
+
// in the coordinator channel, like remote-send.ts does for prompts.
|
|
120
|
+
// Only a run a coordinator prompted wakes it. The user steering the thread, routines and
|
|
121
|
+
// other senders leave a silent note in the channel's coordinator.
|
|
122
|
+
const sentBy = sender?.sessionId ? await coordinatorOf(bot, sender.sessionId) : null
|
|
123
|
+
const wake = sentBy !== null
|
|
124
|
+
const target = sentBy ?? (isCoordinatorChannel(bot, channelId) ? bot.store.getState().roots[channelId] : undefined)
|
|
125
|
+
if (!target || target === run.sessionId) return
|
|
126
|
+
if (sender?.waitUntil !== undefined && Date.now() < sender.waitUntil && (await isWaiting(bot, target))) return
|
|
127
|
+
const answer = run.error ?? finalAnswer(messages) ?? 'Finished without a text answer.'
|
|
128
|
+
const state = run.error ? 'error' : 'completed'
|
|
129
|
+
const title = info.title || 'Untitled'
|
|
130
|
+
const routine = marker?.task ? ` routine="#${marker.task.id}"` : ''
|
|
131
|
+
// Context size and run length, so the coordinator knows when a follow-up would rewrite a big cache.
|
|
132
|
+
const usage = sessionUsage(messages)
|
|
133
|
+
const stats = usage ? ` context="${formatTokens(usage.contextTokens)} tokens"${usage.runMs === null ? '' : ` duration="${formatDuration(usage.runMs)}"`}` : ''
|
|
134
|
+
const text = [
|
|
135
|
+
`<session-finished sessionID="${run.sessionId}" threadID="${run.threadId}" state="${state}" title="${escapeAttribute(title)}"${routine}${stats} wake="${wake}">`,
|
|
136
|
+
// The user steered this thread directly: the coordinator must know what they asked.
|
|
137
|
+
...(user ? [`<user-prompt username="${escapeAttribute(user.username)}">\n${cut(user.text, USER_PROMPT_CHARS)}\n</user-prompt>`] : []),
|
|
138
|
+
cut(answer, wake ? WAKE_ANSWER_CHARS : NOTE_ANSWER_CHARS),
|
|
139
|
+
`More: kimaki session read ${run.sessionId}`,
|
|
140
|
+
...(wake ? [`The user sees nothing else about this. When it is work they asked for, a failure or a decision they must make, tell them the outcome in a few plain sentences, with the thread link <#${run.threadId}> once. Otherwise reply exactly ${IDLE_REPLY}.`] : []),
|
|
141
|
+
'</session-finished>',
|
|
142
|
+
].join('\n')
|
|
143
|
+
const sent = await oc(bot, 'session.synthetic', (client) =>
|
|
144
|
+
client.session.synthetic({
|
|
145
|
+
sessionID: target,
|
|
146
|
+
text,
|
|
147
|
+
description: `${title} finished`,
|
|
148
|
+
metadata: { source: 'kimaki-session', sessionID: run.sessionId, threadID: run.threadId, title, state, wake },
|
|
149
|
+
resume: wake,
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
if (sent instanceof Error) return logger.warn(`note of ${run.sessionId} to ${target}`, sent)
|
|
153
|
+
logger.log(`${wake ? 'woke' : 'noted'} coordinator ${target}: ${run.sessionId} ${state}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createCoordinatorNotes() {
|
|
157
|
+
const registered: { bot: Bot | null } = { bot: null }
|
|
158
|
+
return {
|
|
159
|
+
register(bot: Bot) {
|
|
160
|
+
registered.bot = bot
|
|
161
|
+
},
|
|
162
|
+
runEnded(run: RunEnded) {
|
|
163
|
+
const bot = registered.bot
|
|
164
|
+
// No channel in coordinator mode: no note to send, so no OpenCode reads.
|
|
165
|
+
if (bot && (bot.coordinatorDefault || Object.values(bot.store.getState().coordinator).includes(true))) void noteRunEnded(bot, run)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/db.test.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { render } from 'pele'
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { computeCustomProperties, renderDiagram, withColorClasses } from './diagram.ts'
|
|
5
|
+
|
|
6
|
+
const NESTED = `flowchart LR
|
|
7
|
+
subgraph outer[Outer]
|
|
8
|
+
subgraph inner[Inner]
|
|
9
|
+
a[A]:::blue --> b[(B)]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
b -->|label| c{C}
|
|
13
|
+
classDef blue fill:#0b1f38,stroke:#1d68c5`
|
|
14
|
+
|
|
15
|
+
test('computes inherited custom properties, fallbacks and color-mix', () => {
|
|
16
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" style="--_a:var(--theme-a,#ffffff);--_b:var(--missing,#000000);width:10px"><g style="--_a:#ff0000"><rect fill="var(--_a)" stroke="color-mix(in srgb,var(--_a) 50%,var(--_b))"/></g><rect fill="var(--_a)" style="rx:var(--_r, 4px)"/></svg>`
|
|
17
|
+
expect(computeCustomProperties(svg, new Map([['--theme-a', '#00ff00']]))).toMatchInlineSnapshot(`"<svg xmlns="http://www.w3.org/2000/svg" style="width:10px"><g><rect fill="#ff0000" stroke="#800000"/></g><rect fill="#00ff00" rx="4px"/></svg>"`)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('leaves no var() or color-mix() in pele output', () => {
|
|
21
|
+
const svg = computeCustomProperties(render(NESTED, { padding: 24 }).svg)
|
|
22
|
+
expect(svg).not.toContain('var(')
|
|
23
|
+
expect(svg).not.toContain('color-mix')
|
|
24
|
+
expect(svg).toContain('fill="#ffffff"')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('renders a png and reports syntax errors with position', () => {
|
|
28
|
+
const png = renderDiagram(NESTED)
|
|
29
|
+
if (png instanceof Error) throw png
|
|
30
|
+
expect(png.subarray(1, 4).toString()).toBe('PNG')
|
|
31
|
+
const dark = renderDiagram(NESTED, { theme: 'dark' })
|
|
32
|
+
if (dark instanceof Error) throw dark
|
|
33
|
+
expect(dark.equals(png)).toBe(false)
|
|
34
|
+
|
|
35
|
+
const error = renderDiagram('flowchart LR\n a --> \n b[unclosed')
|
|
36
|
+
expect(error instanceof Error ? error.message : 'no error').toMatchInlineSnapshot(`
|
|
37
|
+
"Parse error on line 11:
|
|
38
|
+
|
|
39
|
+
^
|
|
40
|
+
Expecting 'SQE', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got '$end'"
|
|
41
|
+
`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('adds built-in color classes the source does not define', () => {
|
|
45
|
+
expect(withColorClasses('flowchart LR\n a:::blue --> b:::teal\n classDef teal,red fill:#000')).toMatchInlineSnapshot(`
|
|
46
|
+
"flowchart LR
|
|
47
|
+
a:::blue --> b:::teal
|
|
48
|
+
classDef teal,red fill:#000
|
|
49
|
+
classDef blue fill:#edf5fb,stroke:#a0c3e2,color:#141413
|
|
50
|
+
classDef green fill:#eef6f0,stroke:#a7c8af,color:#141413
|
|
51
|
+
classDef purple fill:#f3effa,stroke:#bfb1de,color:#141413
|
|
52
|
+
classDef pink fill:#fbeef3,stroke:#e0abbf,color:#141413
|
|
53
|
+
classDef amber fill:#fbf5e8,stroke:#e2c893,color:#141413"
|
|
54
|
+
`)
|
|
55
|
+
expect(withColorClasses('sequenceDiagram\n A->>B: hi')).toBe('sequenceDiagram\n A->>B: hi')
|
|
56
|
+
})
|
package/src/diagram.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// `kimaki diagram`: renders Mermaid source to a PNG for Discord, which shows
|
|
2
|
+
// SVG attachments as code, not as images. pele (https://pele.run) lays out
|
|
3
|
+
// the diagram as SVG in pure JS; resvg rasterizes it with the bundled Geist fonts.
|
|
4
|
+
// Loaded lazily by the CLI command, so the static imports cost nothing at startup.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
10
|
+
import * as errore from 'errore'
|
|
11
|
+
import { DomUtils, parseDocument } from 'htmlparser2'
|
|
12
|
+
import { detectType, render } from 'pele'
|
|
13
|
+
import postcss from 'postcss'
|
|
14
|
+
import valueParser, { type Node as ValueNode } from 'postcss-value-parser'
|
|
15
|
+
|
|
16
|
+
const FONTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'fonts')
|
|
17
|
+
const SCALE = 2
|
|
18
|
+
const PADDING = 24
|
|
19
|
+
// Inherited by every shape without its own width, edges included.
|
|
20
|
+
const STROKE_WIDTH = 1.5
|
|
21
|
+
// Share of the hue in a color class border; the rest is its fill, for soft borders.
|
|
22
|
+
const BORDER_HUE = 0.5
|
|
23
|
+
// Discord shrinks long images until labels are unreadable; a square one reads best.
|
|
24
|
+
// The compact candidate asks pele for the narrowest layout: wrapped labels and `LR`
|
|
25
|
+
// turned into `TB` (https://pele.run/api#narrow-screens).
|
|
26
|
+
const SQUARE_CANDIDATES = [{ autoDirection: false }, { maxWidth: 1, autoDirection: true, directionBreakpoint: Infinity }]
|
|
27
|
+
|
|
28
|
+
type ColorName = 'blue' | 'green' | 'purple' | 'pink' | 'amber' | 'red' | 'teal'
|
|
29
|
+
type Palette = { readonly [name in ColorName]: { fill: string; stroke: string } }
|
|
30
|
+
|
|
31
|
+
// Category hues, built in as classDefs so diagrams only write `node:::blue`.
|
|
32
|
+
// Light: the claude-style palette of the diagrams-and-charts skill. Dark: Vercel/Next.js docs.
|
|
33
|
+
const LIGHT_COLORS: Palette = {
|
|
34
|
+
blue: { fill: '#edf5fb', stroke: '#5290c8' },
|
|
35
|
+
green: { fill: '#eef6f0', stroke: '#5f9a6d' },
|
|
36
|
+
purple: { fill: '#f3effa', stroke: '#8a72c2' },
|
|
37
|
+
pink: { fill: '#fbeef3', stroke: '#c4688a' },
|
|
38
|
+
amber: { fill: '#fbf5e8', stroke: '#c99a3e' },
|
|
39
|
+
red: { fill: '#fbeeee', stroke: '#c8524f' },
|
|
40
|
+
teal: { fill: '#ebf6f4', stroke: '#3f9a8f' },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DARK_COLORS: Palette = {
|
|
44
|
+
blue: { fill: '#0b1f38', stroke: '#1d68c5' },
|
|
45
|
+
green: { fill: '#1a2c1e', stroke: '#49a45f' },
|
|
46
|
+
purple: { fill: '#251931', stroke: '#8955a7' },
|
|
47
|
+
pink: { fill: '#371523', stroke: '#c14c70' },
|
|
48
|
+
amber: { fill: '#4c3d20', stroke: '#f2b342' },
|
|
49
|
+
red: { fill: '#3c1618', stroke: '#e5484d' },
|
|
50
|
+
teal: { fill: '#0d2d2a', stroke: '#12a594' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type DiagramTheme = 'light' | 'dark'
|
|
54
|
+
|
|
55
|
+
type Theme = { colors: Palette; vars: ReadonlyMap<string, string> }
|
|
56
|
+
|
|
57
|
+
// pele's CSS variables (https://pele.run/theming).
|
|
58
|
+
function theme({ colors, base }: { colors: Palette; base: Record<string, string> }): Theme {
|
|
59
|
+
const vars = new Map(Object.entries({
|
|
60
|
+
...base,
|
|
61
|
+
'--pele-radius': '10px',
|
|
62
|
+
'--pele-font': 'Geist, sans-serif',
|
|
63
|
+
'--pele-font-mono': 'Geist Mono, monospace',
|
|
64
|
+
'--pele-series-1': colors.blue.stroke,
|
|
65
|
+
'--pele-series-2': colors.amber.stroke,
|
|
66
|
+
'--pele-series-3': colors.green.stroke,
|
|
67
|
+
'--pele-series-4': colors.pink.stroke,
|
|
68
|
+
'--pele-series-5': colors.purple.stroke,
|
|
69
|
+
'--pele-series-6': base['--pele-accent']!,
|
|
70
|
+
'--pele-series-7': colors.red.stroke,
|
|
71
|
+
'--pele-series-8': colors.teal.stroke,
|
|
72
|
+
}))
|
|
73
|
+
return { colors, vars }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const THEMES: { readonly [name in DiagramTheme]: Theme } = {
|
|
77
|
+
light: theme({
|
|
78
|
+
colors: LIGHT_COLORS,
|
|
79
|
+
base: {
|
|
80
|
+
'--pele-bg': '#faf9f5',
|
|
81
|
+
'--pele-fg': '#141413',
|
|
82
|
+
'--pele-muted': '#72736e',
|
|
83
|
+
'--pele-line': '#8c8b85',
|
|
84
|
+
'--pele-surface': '#ffffff',
|
|
85
|
+
'--pele-surface-alt': '#efeee5',
|
|
86
|
+
'--pele-border': '#d1cfc5',
|
|
87
|
+
'--pele-accent': '#d97757',
|
|
88
|
+
'--pele-critical': LIGHT_COLORS.pink.stroke,
|
|
89
|
+
'--pele-error': LIGHT_COLORS.red.stroke,
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
dark: theme({
|
|
93
|
+
colors: DARK_COLORS,
|
|
94
|
+
base: {
|
|
95
|
+
'--pele-bg': '#0a0a0a',
|
|
96
|
+
'--pele-fg': '#ededed',
|
|
97
|
+
'--pele-muted': '#8f8f8f',
|
|
98
|
+
'--pele-line': '#878787',
|
|
99
|
+
'--pele-surface': '#171717',
|
|
100
|
+
'--pele-surface-alt': '#232323',
|
|
101
|
+
'--pele-border': '#3d3d3d',
|
|
102
|
+
'--pele-accent': '#4a8fe7',
|
|
103
|
+
'--pele-critical': DARK_COLORS.pink.stroke,
|
|
104
|
+
'--pele-error': DARK_COLORS.red.stroke,
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class DiagramError extends errore.createTaggedError({
|
|
110
|
+
name: 'DiagramError',
|
|
111
|
+
message: '$reason',
|
|
112
|
+
}) {}
|
|
113
|
+
|
|
114
|
+
// resvg and librsvg do not support CSS custom properties, which pele uses for
|
|
115
|
+
// every color. This does the browser's job on the parsed SVG: custom properties
|
|
116
|
+
// inherit down the element tree, and every var() and color-mix() in attributes
|
|
117
|
+
// and inline styles is computed to a plain value. resvg also ignores rx/ry in
|
|
118
|
+
// style (pele writes rx:var(--_r)), so they move to attributes.
|
|
119
|
+
// TODO: drop this once resvg resolves var(). https://github.com/linebender/resvg/issues/821
|
|
120
|
+
export function computeCustomProperties(svg: string, vars: ReadonlyMap<string, string> = THEMES.light.vars): string {
|
|
121
|
+
const document = parseDocument(svg, { xmlMode: true })
|
|
122
|
+
for (const child of document.children) visit(child, vars)
|
|
123
|
+
return DomUtils.getOuterHTML(document, { xmlMode: true })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type DomNode = ReturnType<typeof parseDocument>['children'][number]
|
|
127
|
+
|
|
128
|
+
function visit(node: DomNode, inherited: ReadonlyMap<string, string>) {
|
|
129
|
+
if (!DomUtils.isTag(node)) return
|
|
130
|
+
const vars = new Map(inherited)
|
|
131
|
+
const { style, ...attributes } = node.attribs
|
|
132
|
+
if (style) {
|
|
133
|
+
const declarations = postcss.parse(style).nodes.filter((child) => child.type === 'decl')
|
|
134
|
+
// Custom properties first: they apply to this element's own declarations too.
|
|
135
|
+
for (const decl of declarations) if (decl.prop.startsWith('--')) vars.set(decl.prop, computeValue(decl.value, vars))
|
|
136
|
+
for (const decl of declarations) if (decl.prop === 'rx' || decl.prop === 'ry') node.attribs[decl.prop] = computeValue(decl.value, vars)
|
|
137
|
+
const kept = declarations
|
|
138
|
+
.filter((decl) => !decl.prop.startsWith('--') && decl.prop !== 'rx' && decl.prop !== 'ry')
|
|
139
|
+
.map((decl) => `${decl.prop}:${computeValue(decl.value, vars)}`)
|
|
140
|
+
if (kept.length > 0) node.attribs.style = kept.join(';')
|
|
141
|
+
else delete node.attribs.style
|
|
142
|
+
}
|
|
143
|
+
for (const [name, value] of Object.entries(attributes)) node.attribs[name] = computeValue(value, vars)
|
|
144
|
+
for (const child of node.children) visit(child, vars)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function computeValue(value: string, vars: ReadonlyMap<string, string>): string {
|
|
148
|
+
if (!value.includes('var(') && !value.includes('color-mix(')) return value
|
|
149
|
+
const parsed = valueParser(value)
|
|
150
|
+
computeNodes(parsed.nodes, vars)
|
|
151
|
+
return parsed.toString()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Replaces var() and color-mix() function nodes in place with word nodes.
|
|
155
|
+
function computeNodes(nodes: ValueNode[], vars: ReadonlyMap<string, string>) {
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
if (node.type !== 'function') continue
|
|
158
|
+
computeNodes(node.nodes, vars)
|
|
159
|
+
if (node.value === 'var') {
|
|
160
|
+
const [name, ...rest] = splitArgs(node.nodes)
|
|
161
|
+
const fallback = rest.length > 0 ? rest.join(',') : undefined
|
|
162
|
+
replaceWithWord(node, vars.get(name ?? '') ?? fallback ?? 'initial')
|
|
163
|
+
} else if (node.value === 'color-mix') {
|
|
164
|
+
const mixed = colorMix(splitArgs(node.nodes))
|
|
165
|
+
if (mixed) replaceWithWord(node, mixed)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function splitArgs(nodes: ValueNode[]): string[] {
|
|
171
|
+
const args: ValueNode[][] = [[]]
|
|
172
|
+
for (const node of nodes) {
|
|
173
|
+
if (node.type === 'div' && node.value === ',') args.push([])
|
|
174
|
+
else args.at(-1)!.push(node)
|
|
175
|
+
}
|
|
176
|
+
return args.map((arg) => valueParser.stringify(arg).trim())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function replaceWithWord(node: ValueNode, value: string) {
|
|
180
|
+
Object.assign(node, { type: 'word', value, nodes: undefined })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// color-mix(in srgb, <hex> [<pct>], <hex> [<pct>]): the only form pele emits.
|
|
184
|
+
function colorMix([space, first, second]: string[]): string | undefined {
|
|
185
|
+
if (space !== 'in srgb' || !first || !second) return undefined
|
|
186
|
+
const [colorA, percentA] = first.split(/\s+/)
|
|
187
|
+
const [colorB, percentB] = second.split(/\s+/)
|
|
188
|
+
const weightA = percentA ? parseFloat(percentA) / 100 : percentB ? 1 - parseFloat(percentB) / 100 : 0.5
|
|
189
|
+
return mixHex({ a: colorA, b: colorB, weightA })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mixHex({ a, b, weightA }: { a: string | undefined; b: string | undefined; weightA: number }): string | undefined {
|
|
193
|
+
const channelsA = hexChannels(a)
|
|
194
|
+
const channelsB = hexChannels(b)
|
|
195
|
+
if (!channelsA || !channelsB) return undefined
|
|
196
|
+
return `#${channelsA.map((channel, i) => Math.round(channel * weightA + channelsB[i]! * (1 - weightA)).toString(16).padStart(2, '0')).join('')}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hexChannels(color: string | undefined): number[] | undefined {
|
|
200
|
+
if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return undefined
|
|
201
|
+
return [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Renders Mermaid source to PNG bytes. pele syntax errors already include the line and a pointer.
|
|
205
|
+
// Diagram types whose grammar accepts classDef lines.
|
|
206
|
+
const CLASS_DEF_TYPES = new Set(['flowchart', 'swimlane', 'state', 'class', 'er', 'requirement'])
|
|
207
|
+
|
|
208
|
+
// Appends a classDef per color the source does not define itself.
|
|
209
|
+
export function withColorClasses(source: string, theme: DiagramTheme = 'light'): string {
|
|
210
|
+
const type = detectType(source)
|
|
211
|
+
if (!type || !CLASS_DEF_TYPES.has(type)) return source
|
|
212
|
+
const { colors, vars } = THEMES[theme]
|
|
213
|
+
const defined = new Set(source.split('\n').flatMap((line) => line.trim().match(/^classDef\s+(\S+)/)?.[1]?.split(',') ?? []))
|
|
214
|
+
const lines = Object.entries(colors)
|
|
215
|
+
.filter(([name]) => !defined.has(name))
|
|
216
|
+
.map(([name, { fill, stroke }]) => ` classDef ${name} fill:${fill},stroke:${mixHex({ a: stroke, b: fill, weightA: BORDER_HUE })},color:${vars.get('--pele-fg')}`)
|
|
217
|
+
return [source.trimEnd(), ...lines].join('\n')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function renderDiagram(source: string, { theme = 'light' }: { theme?: DiagramTheme } = {}): DiagramError | Buffer {
|
|
221
|
+
const { vars } = THEMES[theme]
|
|
222
|
+
const colored = withColorClasses(source, theme)
|
|
223
|
+
const renders = errore.try(
|
|
224
|
+
() => SQUARE_CANDIDATES.map((layout) => render(colored, { fontFamily: 'Geist', fontFamilyMono: 'Geist Mono', padding: PADDING, responsive: false, links: false, images: false, ...layout })),
|
|
225
|
+
(error) => new DiagramError({ reason: error instanceof Error ? error.message : String(error) }),
|
|
226
|
+
)
|
|
227
|
+
if (renders instanceof Error) return renders
|
|
228
|
+
const squareness = ({ width, height }: { width: number; height: number }) => Math.abs(Math.log(width / height))
|
|
229
|
+
const result = renders.reduce((best, candidate) => (squareness(candidate) < squareness(best) ? candidate : best))
|
|
230
|
+
const fontFiles = fs.readdirSync(FONTS_DIR).filter((file) => file.endsWith('.ttf')).map((file) => path.join(FONTS_DIR, file))
|
|
231
|
+
return errore.try(
|
|
232
|
+
() => {
|
|
233
|
+
const svg = result.svg.replace('<svg ', `<svg stroke-width="${STROKE_WIDTH}" `)
|
|
234
|
+
const resvg = new Resvg(computeCustomProperties(svg, vars), {
|
|
235
|
+
background: vars.get('--pele-bg'),
|
|
236
|
+
fitTo: { mode: 'zoom', value: SCALE },
|
|
237
|
+
font: { loadSystemFonts: false, fontFiles, defaultFontFamily: 'Geist', sansSerifFamily: 'Geist', monospaceFamily: 'Geist Mono' },
|
|
238
|
+
})
|
|
239
|
+
return resvg.render().asPng()
|
|
240
|
+
},
|
|
241
|
+
(error) => new DiagramError({ reason: `Rasterizing failed: ${error instanceof Error ? error.message : String(error)}`, cause: error }),
|
|
242
|
+
)
|
|
243
|
+
}
|
package/src/duration.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
// Relative durations like `30m` or `
|
|
1
|
+
// Relative durations like `30m`, `2h` or `1 hour` (`session wait --timeout`, `kimaki sleep --duration`).
|
|
2
2
|
// Dependency-light so the CLI can use it without loading bot modules.
|
|
3
3
|
|
|
4
4
|
import { ConfigError } from './errors.ts'
|
|
5
5
|
|
|
6
|
-
const DURATION = /^(\d+(?:\.\d+)?)\s*(
|
|
7
|
-
const
|
|
6
|
+
const DURATION = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i
|
|
7
|
+
const UNITS: ReadonlyArray<[number, string[]]> = [
|
|
8
|
+
[1, ['ms', 'milli', 'millis']],
|
|
9
|
+
[1_000, ['s', 'second', 'seconds']],
|
|
10
|
+
[60_000, ['m', 'minute', 'minutes']],
|
|
11
|
+
[3_600_000, ['h', 'hour', 'hours']],
|
|
12
|
+
[86_400_000, ['d', 'day', 'days']],
|
|
13
|
+
]
|
|
14
|
+
const DURATION_MS = new Map(UNITS.flatMap(([ms, names]) => names.map((name) => [name, ms] as const)))
|
|
8
15
|
|
|
9
16
|
// Milliseconds of a positive duration: a number plus ms, s, m, h, or d.
|
|
10
17
|
// `label` names the input in errors, for example a flag like `--duration`.
|
|
11
18
|
export function parseDuration(text: string, label = 'duration'): ConfigError | number {
|
|
12
19
|
const trimmed = text.trim()
|
|
13
20
|
const match = DURATION.exec(trimmed)
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
const unit = match ? DURATION_MS.get(match[2]!.toLowerCase()) : undefined
|
|
22
|
+
if (!match || !unit) return new ConfigError({ reason: `Invalid ${label} "${trimmed}". Use a number plus ms, s, m, h, or d (example: 2h)` })
|
|
23
|
+
const amount = Number(match[1]) * unit
|
|
16
24
|
if (!(amount > 0)) return new ConfigError({ reason: `${label.charAt(0).toUpperCase()}${label.slice(1)} must be greater than 0` })
|
|
17
25
|
return amount
|
|
18
26
|
}
|