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,401 @@
|
|
|
1
|
+
// Coordinator sessions in the OpenCode process: their role instructions (below)
|
|
2
|
+
// and the prompt cache warmup of Kimaki sessions.
|
|
3
|
+
//
|
|
4
|
+
// Warmup works like OpenCode's `warming` (core/src/plugin/warming.ts): a
|
|
5
|
+
// `session.generate` keep-alive reads the cached prompt prefix, which keeps the
|
|
6
|
+
// provider cache alive, and stores nothing in the session. It runs while a
|
|
7
|
+
// coordinator's channel is busy, so the <session-finished> note of a worker
|
|
8
|
+
// reads a warm cache, and while a session sleeps (`kimaki sleep` of at most 45
|
|
9
|
+
// minutes), so its wake does:
|
|
10
|
+
//
|
|
11
|
+
// model request ─▶ intervalMs later: sleeping, or coordinator ─▶ keep-alive, again intervalMs later
|
|
12
|
+
// channel busy (at most maxMs
|
|
13
|
+
// after the last real request)?
|
|
14
|
+
// └─▶ no ─▶ end
|
|
15
|
+
// end of a coordinator ─▶ compact while the cache still holds, when the context is big
|
|
16
|
+
//
|
|
17
|
+
// Only the bot knows the threads of a channel and the sleeps: it answers
|
|
18
|
+
// `session.warmup` on its lock port. The warmup is also in plugin storage, so a
|
|
19
|
+
// plugin reload (every Kimaki build or upgrade) continues it. Root Kimaki
|
|
20
|
+
// sessions only, never subagents or TUI sessions.
|
|
21
|
+
// TODO: let the coordinator compact worker sessions too (they keep their full history now).
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import type { Plugin } from '@opencode/plugin'
|
|
26
|
+
import type { SessionContext } from '@opencode/plugin/promise/session'
|
|
27
|
+
import dedent from 'string-dedent'
|
|
28
|
+
|
|
29
|
+
// The whole answer of a coordinator turn with nothing for the user. The reducer drops it.
|
|
30
|
+
export const IDLE_REPLY = 'IDLE'
|
|
31
|
+
|
|
32
|
+
// OpenCode's warming defaults: a keep-alive every 4 minutes stays under Anthropic's
|
|
33
|
+
// 5-minute cache TTL. Same prompt, so OpenCode's own warming, when on, ignores ours.
|
|
34
|
+
const WARM_INTERVAL_MS = 4 * 60_000
|
|
35
|
+
const WARM_PROMPT = 'This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK'
|
|
36
|
+
// A stuck worker must not keep the coordinator warm forever.
|
|
37
|
+
const WARM_MAX_MS = 90 * 60_000
|
|
38
|
+
// A small history is cheap to keep: compacting it only loses detail. Tokens, not a
|
|
39
|
+
// share of the window: a coordinator at 190k never reached 20% of a 1M window.
|
|
40
|
+
const MIN_COMPACT_TOKENS = 100_000
|
|
41
|
+
// Missed while no plugin ran (OpenCode was down): the cache may be cold.
|
|
42
|
+
const STALE_MS = 60_000
|
|
43
|
+
const BUSY_TIMEOUT_MS = 5_000
|
|
44
|
+
const STORAGE_PREFIX = 'warm/'
|
|
45
|
+
|
|
46
|
+
type Timer = ReturnType<typeof setTimeout>
|
|
47
|
+
type Windows = { intervalMs: number; maxMs: number; coordinator: boolean }
|
|
48
|
+
// last: the latest model request, keep-alives included. active: the latest real one.
|
|
49
|
+
type Warmup = { directory: string; last: number; active: number }
|
|
50
|
+
|
|
51
|
+
function markerValue(session: RoleSession, key: string): unknown {
|
|
52
|
+
const kimaki = session.metadata?.['kimaki']
|
|
53
|
+
return kimaki && typeof kimaki === 'object' ? Reflect.get(kimaki, key) : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Root Kimaki sessions only. A `warmup` marker field overrides the windows (tests).
|
|
57
|
+
function readWindows(session: RoleSession): Windows | null {
|
|
58
|
+
if (session.parentID || !markerString(session, 'threadId')) return null
|
|
59
|
+
const warmup = markerValue(session, 'warmup')
|
|
60
|
+
const read = (key: string, fallback: number) => {
|
|
61
|
+
const value: unknown = warmup && typeof warmup === 'object' ? Reflect.get(warmup, key) : null
|
|
62
|
+
return typeof value === 'number' && value > 0 ? value : fallback
|
|
63
|
+
}
|
|
64
|
+
return { intervalMs: read('intervalMs', WARM_INTERVAL_MS), maxMs: read('maxMs', WARM_MAX_MS), coordinator: !!coordinatorChannel(session) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readWarmup(value: unknown): Warmup | null {
|
|
68
|
+
if (!value || typeof value !== 'object') return null
|
|
69
|
+
const directory: unknown = Reflect.get(value, 'directory')
|
|
70
|
+
const last: unknown = Reflect.get(value, 'last')
|
|
71
|
+
const active: unknown = Reflect.get(value, 'active')
|
|
72
|
+
return typeof directory === 'string' && typeof last === 'number' && typeof active === 'number' ? { directory, last, active } : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// busy: a coordinator or a thread of its channel runs. sleeping: a sleep of at most
|
|
76
|
+
// 45 minutes waits for its wake. From the bot (lock-routes.ts); no answer (bot stopped) ends the warmup.
|
|
77
|
+
async function warmupState(session: RoleSession): Promise<{ busy: boolean; sleeping: boolean }> {
|
|
78
|
+
const idle = { busy: false, sleeping: false }
|
|
79
|
+
const dataDir = markerString(session, 'dataDir')
|
|
80
|
+
const lockPort = markerValue(session, 'lockPort')
|
|
81
|
+
if (!dataDir || typeof lockPort !== 'number') return idle
|
|
82
|
+
const token = await fs.promises.readFile(path.join(dataDir, 'lock-token'), 'utf8').catch(() => null)
|
|
83
|
+
if (token === null) return idle
|
|
84
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}/kimaki/session.warmup`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ sessionId: session.id }),
|
|
88
|
+
signal: AbortSignal.timeout(BUSY_TIMEOUT_MS),
|
|
89
|
+
}).catch(() => null)
|
|
90
|
+
if (!response?.ok) return idle
|
|
91
|
+
const body: unknown = await response.json().catch(() => null)
|
|
92
|
+
if (!body || typeof body !== 'object') return idle
|
|
93
|
+
return { busy: Reflect.get(body, 'busy') === true, sleeping: Reflect.get(body, 'sleeping') === true }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function registerWarmup(ctx: Plugin.Context): Promise<() => void> {
|
|
97
|
+
// The marker never changes after create: one session.get per session, also for TUI ones.
|
|
98
|
+
const known = new Map<string, Promise<Windows | null>>()
|
|
99
|
+
const warmups = new Map<string, Warmup>()
|
|
100
|
+
const timers = new Map<string, Timer>()
|
|
101
|
+
|
|
102
|
+
function windows(sessionID: string): Promise<Windows | null> {
|
|
103
|
+
const cached = known.get(sessionID)
|
|
104
|
+
if (cached) return cached
|
|
105
|
+
// A failed read is not cached: it would turn the warmup off until the plugin reloads.
|
|
106
|
+
const found = ctx.session.get({ sessionID }).then(readWindows, () => {
|
|
107
|
+
known.delete(sessionID)
|
|
108
|
+
return null
|
|
109
|
+
})
|
|
110
|
+
known.set(sessionID, found)
|
|
111
|
+
return found
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function worthCompacting(sessionID: string): Promise<boolean> {
|
|
115
|
+
const messages = await ctx.session.context({ sessionID })
|
|
116
|
+
const last = messages.findLast((message) => message.type === 'assistant' && message.tokens)
|
|
117
|
+
const tokens = last?.type === 'assistant' && last.tokens ? last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write : 0
|
|
118
|
+
return tokens >= MIN_COMPACT_TOKENS
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function save(sessionID: string, warmup: Warmup) {
|
|
122
|
+
warmups.set(sessionID, warmup)
|
|
123
|
+
void ctx.storage.set(STORAGE_PREFIX + sessionID, warmup).catch(() => undefined)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function arm(sessionID: string, found: Windows) {
|
|
127
|
+
const warmup = warmups.get(sessionID)
|
|
128
|
+
if (!warmup) return
|
|
129
|
+
clearTimeout(timers.get(sessionID))
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
timers.delete(sessionID)
|
|
132
|
+
// Plugins stay silent: a failed keep-alive or compaction only loses that one.
|
|
133
|
+
void tick(sessionID, warmup, found).catch(() => undefined)
|
|
134
|
+
}, Math.max(0, warmup.last + found.intervalMs - Date.now()))
|
|
135
|
+
timer.unref?.()
|
|
136
|
+
timers.set(sessionID, timer)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function tick(sessionID: string, warmup: Warmup, found: Windows) {
|
|
140
|
+
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
141
|
+
const state = session ? await warmupState(session) : null
|
|
142
|
+
// The bot bounds sleeps (45 minutes); the plugin bounds busy channels, against stuck workers.
|
|
143
|
+
const warm = !!state && (state.sleeping || (state.busy && Date.now() < warmup.active + found.maxMs))
|
|
144
|
+
// A model request during the checks moved the warmup.
|
|
145
|
+
if (warmups.get(sessionID) !== warmup) return
|
|
146
|
+
if (warm) {
|
|
147
|
+
// Before the request, so a failing keep-alive waits a whole interval.
|
|
148
|
+
save(sessionID, { ...warmup, last: Date.now() })
|
|
149
|
+
arm(sessionID, found)
|
|
150
|
+
await ctx.session.generate({ sessionID, prompt: WARM_PROMPT })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
warmups.delete(sessionID)
|
|
154
|
+
await ctx.storage.remove(STORAGE_PREFIX + sessionID).catch(() => undefined)
|
|
155
|
+
if (found.coordinator && (await worthCompacting(sessionID))) await ctx.session.compact({ sessionID, delivery: 'queue' })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function touch(sessionID: string, real: boolean) {
|
|
159
|
+
const found = await windows(sessionID)
|
|
160
|
+
if (!found) return
|
|
161
|
+
const current = warmups.get(sessionID)
|
|
162
|
+
// Keep-alives (also OpenCode's own warming) only move a running warmup.
|
|
163
|
+
if (!real && !current) return
|
|
164
|
+
const now = Date.now()
|
|
165
|
+
save(sessionID, { directory: ctx.location.directory, last: now, active: real || !current ? now : current.active })
|
|
166
|
+
arm(sessionID, found)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Warmups of the plugin before a reload. Storage is shared by all locations: each takes its own.
|
|
170
|
+
const stored = await ctx.storage.scan({ prefix: STORAGE_PREFIX }).catch(() => null)
|
|
171
|
+
await Promise.all((stored?.entries ?? []).map(async ({ key, value }) => {
|
|
172
|
+
const sessionID = key.slice(STORAGE_PREFIX.length)
|
|
173
|
+
const warmup = readWarmup(value)
|
|
174
|
+
if (!sessionID || warmup?.directory !== ctx.location.directory) return
|
|
175
|
+
const found = await windows(sessionID)
|
|
176
|
+
if (!found || Date.now() > warmup.last + found.intervalMs + STALE_MS) {
|
|
177
|
+
await ctx.storage.remove(key).catch(() => undefined)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
warmups.set(sessionID, warmup)
|
|
181
|
+
arm(sessionID, found)
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
// Turns of the session start or move the warmup; compaction requests do not.
|
|
185
|
+
await ctx.session.hook('context', (event) => touch(event.sessionID, true))
|
|
186
|
+
await ctx.session.hook('generate', (event) => touch(event.sessionID, false))
|
|
187
|
+
|
|
188
|
+
// Storage keeps the warmups for the next plugin instance.
|
|
189
|
+
return () => {
|
|
190
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
191
|
+
timers.clear()
|
|
192
|
+
warmups.clear()
|
|
193
|
+
known.clear()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Role instructions of coordinator sessions, derived from markers only:
|
|
198
|
+
//
|
|
199
|
+
// root Kimaki session ─▶ marker threadId === channelId? ─▶ coordinator
|
|
200
|
+
// └─▶ parent chain (marker parentSessionId, fork
|
|
201
|
+
// source) reaches a coordinator? ─▶ worker
|
|
202
|
+
//
|
|
203
|
+
// Here, not in the instructions entry the bot writes at session.create: that
|
|
204
|
+
// entry is frozen, while OpenCode reloads this plugin when its code changes.
|
|
205
|
+
// OpenCode has no instruction source for plugins, so the text is a synthetic
|
|
206
|
+
// message, reconciled like its plan plugin (core/src/plugin/plan.ts):
|
|
207
|
+
//
|
|
208
|
+
// user prompt ─▶ latest role text in the history missing (new session,
|
|
209
|
+
// compaction) or stale (new plugin code)? ─▶ append it before the prompt
|
|
210
|
+
//
|
|
211
|
+
// It lands at the tail: the cached prompt prefix stays warm. A turn with no user
|
|
212
|
+
// prompt (a <session-finished> wake after compaction) gets the text on its last
|
|
213
|
+
// user message for that request only, until the next prompt stores it.
|
|
214
|
+
|
|
215
|
+
const ROLE_HEAD = '<system-reminder>\n<kimaki-coordinator>'
|
|
216
|
+
// Workers of workers, forks of forks: bounded against cycles.
|
|
217
|
+
const MAX_PARENT_DEPTH = 10
|
|
218
|
+
|
|
219
|
+
function roleText(body: string): string {
|
|
220
|
+
return [ROLE_HEAD, 'These instructions replace any earlier Kimaki role instructions.', '', body, '</kimaki-coordinator>', '</system-reminder>'].join('\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type RoleSession = Awaited<ReturnType<Plugin.Context['session']['get']>>
|
|
224
|
+
|
|
225
|
+
function markerString(session: RoleSession, key: string): string | null {
|
|
226
|
+
const kimaki = session.metadata?.['kimaki']
|
|
227
|
+
const value: unknown = kimaki && typeof kimaki === 'object' ? Reflect.get(kimaki, key) : null
|
|
228
|
+
return typeof value === 'string' ? value : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function coordinatorChannel(session: RoleSession): string | null {
|
|
232
|
+
const channelId = markerString(session, 'channelId')
|
|
233
|
+
return channelId && markerString(session, 'threadId') === channelId ? channelId : null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parentOf(session: RoleSession): string | undefined {
|
|
237
|
+
return markerString(session, 'parentSessionId') ?? session.fork?.sessionID
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function registerCoordinatorRoles(ctx: Plugin.Context): Promise<void> {
|
|
241
|
+
// The role text this session must have, or null: TUI sessions, subagents, plain threads.
|
|
242
|
+
async function expected(sessionID: string): Promise<string | null> {
|
|
243
|
+
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
244
|
+
if (!session || session.parentID || !markerString(session, 'threadId')) return null
|
|
245
|
+
const channelId = coordinatorChannel(session)
|
|
246
|
+
const dataDir = markerString(session, 'dataDir')
|
|
247
|
+
if (channelId) return dataDir ? roleText(coordinatorSection({ sessionId: sessionID, channelId, dataDir })) : null
|
|
248
|
+
let parent = parentOf(session)
|
|
249
|
+
for (let depth = 0; parent && depth < MAX_PARENT_DEPTH; depth++) {
|
|
250
|
+
const info = await ctx.session.get({ sessionID: parent }).catch(() => null)
|
|
251
|
+
if (!info) return null
|
|
252
|
+
const coordinator = coordinatorChannel(info)
|
|
253
|
+
if (coordinator) return roleText(workerSection(coordinator))
|
|
254
|
+
parent = parentOf(info)
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await ctx.session.hook('prompt', async (event) => {
|
|
260
|
+
const text = await expected(event.sessionID)
|
|
261
|
+
if (!text) return
|
|
262
|
+
const history = await ctx.session.context({ sessionID: event.sessionID }).catch(() => null)
|
|
263
|
+
if (!history) return
|
|
264
|
+
const latest = history.findLast((message) => message.type === 'synthetic' && message.text.startsWith(ROLE_HEAD))
|
|
265
|
+
if (latest?.type === 'synthetic' && latest.text === text) return
|
|
266
|
+
// Plugins stay silent: a failed append is retried at the next prompt.
|
|
267
|
+
await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Every request kind gets the same messages, or the prompt cache misses (plugin/index.ts).
|
|
271
|
+
const shapeRequest = async (event: SessionContext) => {
|
|
272
|
+
const text = await expected(event.sessionID)
|
|
273
|
+
if (!text) return
|
|
274
|
+
const texts = event.messages.flatMap((message) => (message.role === 'user' ? message.content : []))
|
|
275
|
+
.flatMap((part) => (part.type === 'text' && part.text.startsWith(ROLE_HEAD) ? [part.text] : []))
|
|
276
|
+
if (texts.at(-1) === text) return
|
|
277
|
+
const last = event.messages.findLast((message) => message.role === 'user')
|
|
278
|
+
// TODO: insert a new Message like core/src/plugin/plan.ts once plugins can import OpenCode's Message class.
|
|
279
|
+
if (last) Array.prototype.push.call(last.content, { type: 'text', text })
|
|
280
|
+
}
|
|
281
|
+
await ctx.session.hook('context', shapeRequest)
|
|
282
|
+
await ctx.session.hook('compaction', shapeRequest)
|
|
283
|
+
await ctx.session.hook('generate', shapeRequest)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The coordinator of a coordinator channel.
|
|
287
|
+
function coordinatorSection({ sessionId, channelId, dataDir }: { sessionId: string; channelId: string; dataDir: string }): string {
|
|
288
|
+
return dedent`
|
|
289
|
+
## coordinator: you coordinate this channel
|
|
290
|
+
|
|
291
|
+
You run directly in a Discord **channel**, not in a thread. This is one long session the user keeps coming back to, for weeks. Your job is to know what is going on in this project and to get work done through **thread sessions**, which the user can open and talk to. This session is meant to be **infinite**: it is compacted over and over, and every file you read or command output you see fills it up. Keep your context clean: it holds goals, decisions and results, not working details.
|
|
292
|
+
|
|
293
|
+
### delegate the work
|
|
294
|
+
|
|
295
|
+
- Read files, diffs and the repo source yourself to know what is going on: how the project works, what it does and what workers changed. You need this to answer the user and to write good worker prompts. Not everything needs a worker.
|
|
296
|
+
- Delegate the work itself to child sessions, \`kimaki send\` threads or the \`subagent\` tool: editing files, builds, tests, long debugging and big research.
|
|
297
|
+
- Use the \`subagent\` tool only for blocking throwaway work the user never needs to see, to save your context: an \`explore\` search, a quick lookup. Anything the user may want to follow or steer is a thread.
|
|
298
|
+
- When you can answer from what you know, answer directly.
|
|
299
|
+
- Start a worker:
|
|
300
|
+
|
|
301
|
+
kimaki send --channel ${channelId} --name 'Short title' --prompt '...' --agent <agent> --parent-session ${sessionId} --wait 5
|
|
302
|
+
|
|
303
|
+
Do not add the user to worker threads by default. Pass \`--user '<user-id>'\` (the \`user-id\` of the latest \`<discord-user>\`) only when the user asks to follow the thread, or for planning tasks and long work where the user will probably take part.
|
|
304
|
+
- \`--wait 5\` is the default for every \`kimaki send\`: it waits at most 5 seconds. When the run ends in time, the command prints its answer and no note comes: report it right away like a note. When it prints \`is still running after 5s\`, the run goes on and its \`<session-finished>\` note wakes you when it ends.
|
|
305
|
+
- After starting a worker that is still running, say nothing: your whole reply is exactly \`IDLE\`. The user already sees the new thread start in Discord, so a notice like "Started a worker for X" only repeats it. You report the outcome when its note wakes you. Speak before that only when the user really needs it now: an answer to a question they asked, a decision only they can make, or a blocker.
|
|
306
|
+
- Use \`--wait\` with no number (waits until the run ends; shell timeout 20 minutes or more, \`timeout: 1_500_000\`) only when you need the result before your next step.
|
|
307
|
+
- Other projects: \`--channel\` of that project (\`kimaki project list --json\`). Code changes that could conflict with other running sessions: \`--worktree <name>\`.
|
|
308
|
+
- Run sessions in parallel only when their write scopes do not overlap. Otherwise use separate worktrees, or run them one after another.
|
|
309
|
+
- A follow-up for an existing worker: \`kimaki send --thread <thread_id> --prompt '...' --wait 5\`, with \`. queue\` at the end of the prompt when it may be busy. Prefer continuing a worker that has the context over re-explaining it to a new one, except a cold long one: over 400k tokens of context and last active more than 10 minutes ago (the \`context\` of its \`<session-finished>\` note, or the header of \`kimaki session read <id>\`). Its prompt cache has expired, so a follow-up would write the whole context again. Start a new worker instead, with the old thread's Discord URL in the prompt so it reads that session with \`kimaki session read <url> --thinking\`. A follow-up shows only inside its thread, never in this channel. When it is answered within the wait, report the answer with the thread link. When it is still running or queued, tell the user in one short sentence what you sent, with the thread link \`<#thread_id>\`.
|
|
310
|
+
- Related work that needs the same context, in parallel: fork a worker (or a past session) with \`. btw\` at the end, only when it touches different files than the work still running there:
|
|
311
|
+
|
|
312
|
+
kimaki send --thread <thread_id> --prompt '... btw' --agent <agent> --parent-session ${sessionId} --wait 5
|
|
313
|
+
|
|
314
|
+
The fork gets a new thread with the whole history; the source keeps running. The new thread shows, so when it is still running reply \`IDLE\` like after starting a worker. Its \`<session-finished>\` note wakes you like any worker.
|
|
315
|
+
- Link a worker as \`<#thread_id>\` once, in your report of its outcome: no other notice shows that it finished. Also once when you send it a follow-up that is still running. Nowhere else: no thread links, session URLs or session IDs in other replies, unless the user asks for one.
|
|
316
|
+
|
|
317
|
+
### writing prompts for workers
|
|
318
|
+
|
|
319
|
+
A worker starts with no memory of this conversation. It loads AGENTS.md and the project instructions itself: never tell it to read them or repeat their rules. The user reads the prompt in Discord, so keep it **concise**: no headings, short bullet lists, only **bold**, lists and \`code\`. Include only what the worker needs and cannot find on its own.
|
|
320
|
+
|
|
321
|
+
- Always quote the user's words verbatim, in a \`>\` quote. Never summarize or reword them. Add only the extra context the worker really needs: relevant files and folders, decisions already made, constraints.
|
|
322
|
+
- Say **what** the user wants, not **how** to do it: the goal, and what done means (the result to expect). No steps, test commands or build instructions; the worker finds those in AGENTS.md.
|
|
323
|
+
- Scope: what it may change, and what it must not do. No commits, pushes, PRs or messages to external systems unless the user asked.
|
|
324
|
+
- One worker owns one coherent task. Do not split a task into tiny sessions that depend on each other.
|
|
325
|
+
- Recurring how-to (how to run tests, which commands, build steps) and any rule you repeat in many prompts belong in AGENTS.md, not in prompts. When one is missing there, suggest adding it to the user.
|
|
326
|
+
- Pick \`--agent\` by difficulty and cost: a strong model for design and hard debugging, a cheaper one for mechanical edits and searches.
|
|
327
|
+
|
|
328
|
+
### notifications
|
|
329
|
+
|
|
330
|
+
- When a run you prompted ends (a worker you started, a follow-up or a fork you sent), a \`<session-finished ...>\` message with its final answer wakes you. Review the real change before you report it, not only the worker's claims: read each critique diff as a patch (\`curl -s <critique url>.patch\`, for example \`curl -s https://critique.work/v/<id>.patch\`) and the changed files when the patch is not enough. Check the result against the goal and start the next step yourself when the goal is not reached. Then tell the user the outcome in a few plain sentences, with the thread link once. The user sees nothing else about it. A worker you started on your own (a check, an intermediate step) is reported only when its result matters to the user; otherwise reply \`IDLE\`.
|
|
331
|
+
- Files a worker uploads (screenshots, images) show in its thread; the thread link points the user to them. Never upload them again and never ask a worker for their URLs.
|
|
332
|
+
- Every report of changed files or commits has critique diff URLs, one per commit (\`bunx critique --commit <hash> --web 'title'\`). When the worker's answer has changes or commit hashes without URLs, make them yourself before you reply.
|
|
333
|
+
- A run that ends within its \`--wait\` returns its answer in the command output instead, with no note. Report it the same way. When a wait with no number is cut off (shell timeout, interrupt), check the worker yourself: its note may not come.
|
|
334
|
+
- Runs you did not prompt add a \`<session-finished ... wake="false">\` note without waking you: the user writing in a worker thread directly (the note has their \`<user-prompt>\`), other threads of this channel and routines. Read them at the next prompt so you know what happened meanwhile.
|
|
335
|
+
- Reminders, notes and wakes are not a reason to talk. Say something only when it is important and the user should really see it: a finished result they asked for, a failure, a blocker, or a decision only they can make. Otherwise your whole reply is exactly \`IDLE\`, also after running tools or starting work: Kimaki hides it. Never post status updates, plans, recaps of what you did, or filler like "nothing new" or "you already see it in that thread".
|
|
336
|
+
- Starting a worker is not finishing it. When you need its result and no note came, check instead of guessing.
|
|
337
|
+
- Stop a worker's run with \`kimaki session abort <id>\`; its thread stays and can continue later.
|
|
338
|
+
- \`<session-finished>\` notes and reminders are for you. Never mention them to the user.
|
|
339
|
+
- Never say a worker is still working without checking: \`kimaki session list --active\` or \`kimaki session read <id>\`.
|
|
340
|
+
- \`kimaki session read <id>\` prints the prompts and final answers of a session, with its context size and last activity in the header; \`--thinking\` adds every tool call and the reasoning. Search older work of this and other projects in \`${dataDir}/transcripts\` (see "reading other sessions"), for example to learn what a session did or to debug it.
|
|
341
|
+
|
|
342
|
+
### examples
|
|
343
|
+
|
|
344
|
+
Lines in brackets are your tool calls and wakes, not text the user sees.
|
|
345
|
+
|
|
346
|
+
\`\`\`
|
|
347
|
+
user: the login test is flaky, fix it
|
|
348
|
+
you: [kimaki send --channel ... --name 'Fix flaky login test' ... --wait 5, thread 1501 starts, still running after 5s]
|
|
349
|
+
you: IDLE
|
|
350
|
+
user: also make that timeout configurable
|
|
351
|
+
you: [1501 still runs: kimaki send --thread 1501 --prompt '... queue' --wait 5, still running after 5s]
|
|
352
|
+
you: Queued in <#1501>: a configurable login timeout, after the flaky test fix.
|
|
353
|
+
user: and add a logout test meanwhile
|
|
354
|
+
you: [kimaki send --thread 1501 --prompt '... btw' --wait 5, fork thread 1503 starts, still running after 5s]
|
|
355
|
+
you: IDLE
|
|
356
|
+
[<session-finished> of the first run of 1501 wakes you; you read its critique patch]
|
|
357
|
+
you: Fixed in <#1501>: the test now waits for the session cookie before it clicks. https://critique.work/v/a1b2c3
|
|
358
|
+
[<session-finished> of the fork wakes you]
|
|
359
|
+
you: The logout test is added in <#1503>. https://critique.work/v/7f8e9d
|
|
360
|
+
[<session-finished> of the queued run of 1501 wakes you]
|
|
361
|
+
you: The login timeout is now the LOGIN_TIMEOUT_MS env var, 10s by default, in <#1501>. https://critique.work/v/d4e5f6
|
|
362
|
+
user: ask it if it ran the full test suite
|
|
363
|
+
you: [kimaki send --thread 1501 --prompt '...' --wait 5, answered in 3s]
|
|
364
|
+
you: Yes, <#1501> ran the full suite and all tests pass.
|
|
365
|
+
user: now remove the old login fallback
|
|
366
|
+
you: [kimaki send --thread 1501 --prompt '...' --wait 5, still running after 5s]
|
|
367
|
+
you: Sent to <#1501>: remove the old login fallback.
|
|
368
|
+
|
|
369
|
+
user: which database does billing use? and add an index on invoices.user_id
|
|
370
|
+
you: [reads db/schema.prisma, starts a worker for the index]
|
|
371
|
+
you: Billing uses the shared Postgres in db/schema.prisma.
|
|
372
|
+
[<session-finished> wakes you]
|
|
373
|
+
you: The index on invoices.user_id is added in <#1502>. https://critique.work/v/0a9b8c
|
|
374
|
+
|
|
375
|
+
user: refactor the auth module
|
|
376
|
+
you: [finds two auth modules, starts nothing]
|
|
377
|
+
you: Which one: website/src/auth.ts or gateway-proxy/src/auth.rs?
|
|
378
|
+
\`\`\`
|
|
379
|
+
|
|
380
|
+
### memory
|
|
381
|
+
|
|
382
|
+
Keep durable project memory in \`MEMORY.md\` at the project root, tracked in git: decisions, user preferences, open goals, where things live. One \`##\` section per topic, short bullets. Update it when you learn something that must outlive compaction.
|
|
383
|
+
The headings are the index. Never read the whole file; list them, then read only the sections you need:
|
|
384
|
+
|
|
385
|
+
\`\`\`bash
|
|
386
|
+
rg -n '^#+ ' MEMORY.md
|
|
387
|
+
\`\`\`
|
|
388
|
+
`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// A thread of a coordinator channel. A btw fork carries the coordinator's history: it must not take its role.
|
|
392
|
+
// The channel ID always resolves to the current coordinator, also after /reset-coordinator.
|
|
393
|
+
function workerSection(channelId: string): string {
|
|
394
|
+
return dedent`
|
|
395
|
+
## worker of a coordinator
|
|
396
|
+
|
|
397
|
+
This channel has a coordinator session. You are a worker, not the coordinator, even if your history shows its context: do only your own task.
|
|
398
|
+
For high level project context (goals, decisions, what other threads did), read it: \`kimaki session read ${channelId}\`. Project memory: \`rg -n '^#+ ' MEMORY.md\`, then read only the sections you need.
|
|
399
|
+
In your final message, list your critique diff URLs. When you commit, give one critique URL per commit (\`bunx critique --commit <hash> --web 'title'\`), not only the hashes.
|
|
400
|
+
`
|
|
401
|
+
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { JsonValue } from '@opencode/client'
|
|
2
1
|
import { Plugin, Skill } from '@opencode/plugin'
|
|
2
|
+
import type { SessionContext } from '@opencode/plugin/promise/session'
|
|
3
3
|
import { Schema } from 'effect'
|
|
4
4
|
import { execFile } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
@@ -8,13 +8,28 @@ import path from 'node:path'
|
|
|
8
8
|
import { autostartBot } from '../autostart.ts'
|
|
9
9
|
import { readBuiltinSkills } from '../builtin-skills.ts'
|
|
10
10
|
import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
|
|
11
|
-
import {
|
|
11
|
+
import { registerCoordinatorRoles, registerWarmup } from './coordinator.ts'
|
|
12
|
+
import { turnContext } from '../system-prompt.ts'
|
|
12
13
|
|
|
13
14
|
const exec = promisify(execFile)
|
|
14
15
|
|
|
15
16
|
const decodeSkill = Schema.decodeUnknownSync(Skill.Info)
|
|
16
17
|
|
|
17
18
|
const BRANCH_PREFIX = '[current git branch is '
|
|
19
|
+
// Prompt metadata key: ms between the latest assistant message and the prompt.
|
|
20
|
+
const GAP_KEY = 'sinceAssistantMs'
|
|
21
|
+
|
|
22
|
+
// `3d 4h`, `2h 5m`, `12m 5s`, `40s`: enough to tell a quick follow-up from a return after a break.
|
|
23
|
+
function formatGap(ms: number): string {
|
|
24
|
+
const seconds = Math.floor(ms / 1_000)
|
|
25
|
+
const minutes = Math.floor(seconds / 60)
|
|
26
|
+
const hours = Math.floor(minutes / 60)
|
|
27
|
+
const days = Math.floor(hours / 24)
|
|
28
|
+
if (days > 0) return `${days}d ${hours % 24}h`
|
|
29
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
|
30
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
|
|
31
|
+
return `${seconds}s`
|
|
32
|
+
}
|
|
18
33
|
|
|
19
34
|
export default Plugin.define({
|
|
20
35
|
id: 'kimaki',
|
|
@@ -32,78 +47,59 @@ export default Plugin.define({
|
|
|
32
47
|
}
|
|
33
48
|
})
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
async function marker(sessionID: string): Promise<{ fields: Map<string, JsonValue>; root: boolean } | null> {
|
|
50
|
+
async function marker(sessionID: string) {
|
|
37
51
|
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
38
52
|
const value: unknown = session?.metadata?.['kimaki']
|
|
39
53
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
40
54
|
const current = new Map(Object.entries(value))
|
|
41
|
-
if (!session?.parentID) return
|
|
55
|
+
if (!session?.parentID) return current
|
|
42
56
|
const parent = await ctx.session.get({ sessionID: session.parentID }).catch(() => null)
|
|
43
57
|
const parentMarker = parent?.metadata?.['kimaki']
|
|
44
|
-
if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId'))
|
|
45
|
-
|
|
46
|
-
return root && { fields: root.fields, root: false }
|
|
47
|
-
}
|
|
48
|
-
return { fields: current, root: true }
|
|
58
|
+
if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) return marker(session.parentID)
|
|
59
|
+
return current
|
|
49
60
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
editor.add({
|
|
53
|
-
name: MESSAGE_TOOL,
|
|
54
|
-
description: MESSAGE_TOOL_DESCRIPTION,
|
|
55
|
-
input: MESSAGE_TOOL_INPUT,
|
|
56
|
-
options: { codemode: false },
|
|
57
|
-
// Discord renders the call input (thread-reducer.ts); the tool itself does nothing.
|
|
58
|
-
execute: async () => ({ content: 'Message sent to the user.' }),
|
|
59
|
-
})
|
|
60
|
-
})
|
|
61
|
-
// The git branch reaches the model as a synthetic message saved right before
|
|
62
|
-
// the user prompt, only when it changed. Text in event.system would change the
|
|
63
|
-
// cached prompt prefix on every branch switch. The prompt hook runs before
|
|
64
|
-
// OpenCode admits the prompt, so the synthetic message is admitted first.
|
|
61
|
+
// The prompt hook runs before OpenCode admits the prompt, so changes to its
|
|
62
|
+
// metadata are stored with it and a synthetic message saved here comes first.
|
|
65
63
|
await ctx.session.hook('prompt', async (event) => {
|
|
66
64
|
if (!(await marker(event.sessionID))) return
|
|
67
|
-
const
|
|
65
|
+
const [history, branch] = await Promise.all([
|
|
66
|
+
ctx.session.context({ sessionID: event.sessionID }).catch(() => null),
|
|
67
|
+
exec('git', ['branch', '--show-current'], { cwd: ctx.location.directory, timeout: 5000 }).catch(() => null),
|
|
68
|
+
])
|
|
69
|
+
if (!history) return
|
|
70
|
+
// The time since the latest reply is stored on the prompt and rendered by
|
|
71
|
+
// shapeRequest: a value computed per request would change the cached prefix.
|
|
72
|
+
const reply = history.findLast((message) => message.type === 'assistant')
|
|
73
|
+
if (reply?.type === 'assistant') event.metadata = { ...event.metadata, [GAP_KEY]: Math.max(0, Date.now() - (reply.time.completed ?? reply.time.created)) }
|
|
74
|
+
// The git branch is a synthetic message, only when it changed. Text in
|
|
75
|
+
// event.system would change the cached prompt prefix on every branch switch.
|
|
68
76
|
const name = branch?.stdout.trim()
|
|
69
77
|
if (!name) return
|
|
70
78
|
const text = `${BRANCH_PREFIX}${name}]`
|
|
71
|
-
const history = await ctx.session.context({ sessionID: event.sessionID }).catch(() => null)
|
|
72
|
-
if (!history) return
|
|
73
79
|
const last = history.findLast((message) => message.type === 'synthetic' && message.text.startsWith(BRANCH_PREFIX))
|
|
74
80
|
if (last?.type === 'synthetic' && last.text === text) return
|
|
75
81
|
await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
|
|
76
82
|
})
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
async
|
|
81
|
-
const info = await marker(event.sessionID)
|
|
82
|
-
const messageMode = info?.root === true && info.fields.get('messageTool') === true
|
|
83
|
-
if (!messageMode) delete event.tools[MESSAGE_TOOL]
|
|
84
|
-
else event.system.push({ type: 'text', text: MESSAGE_MODE_INSTRUCTIONS })
|
|
85
|
-
return info
|
|
86
|
-
}
|
|
87
|
-
await ctx.session.hook('compaction', async (event) => {
|
|
88
|
-
await applyMessageMode(event)
|
|
89
|
-
})
|
|
90
|
-
await ctx.session.hook('generate', async (event) => {
|
|
91
|
-
await applyMessageMode(event)
|
|
92
|
-
})
|
|
93
|
-
await ctx.session.hook('context', async (event) => {
|
|
83
|
+
// Runs for every request kind: turns (context), compaction and generate (OpenCode
|
|
84
|
+
// `warming` keep-alive requests). All kinds must get the same system text, tools
|
|
85
|
+
// and messages, or the provider prompt cache misses.
|
|
86
|
+
const shapeRequest = async (event: SessionContext) => {
|
|
94
87
|
// The only prompt change for every session, TUI included. The ID never
|
|
95
88
|
// changes in a session, so the cached prompt prefix stays stable.
|
|
96
89
|
event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
90
|
+
if (!(await marker(event.sessionID))) return
|
|
91
|
+
// The time since the previous reply and the <discord-user> context of each
|
|
92
|
+
// prompt, built from its metadata, so the stored text stays clean for the TUI
|
|
93
|
+
// and other clients. Same metadata, same text on every request: the cached
|
|
94
|
+
// prompt prefix stays stable.
|
|
102
95
|
for (const message of event.messages) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
if (message.role !== 'user') continue
|
|
97
|
+
const gap = message.metadata?.[GAP_KEY]
|
|
98
|
+
for (const text of [typeof gap === 'number' ? `[${formatGap(gap)} since your last message]` : null, turnContext(message.metadata)]) {
|
|
99
|
+
// TODO: build a new Message once plugins can import OpenCode's Message class;
|
|
100
|
+
// a copy from another module instance may fail its instanceof checks.
|
|
101
|
+
if (text) Array.prototype.push.call(message.content, { type: 'text', text })
|
|
102
|
+
}
|
|
107
103
|
}
|
|
108
104
|
// Extra inputs only for the Discord tool line. OpenCode decodes the real
|
|
109
105
|
// inputs with Schema.Struct, which ignores unknown keys.
|
|
@@ -121,25 +117,56 @@ export default Plugin.define({
|
|
|
121
117
|
...properties,
|
|
122
118
|
} }
|
|
123
119
|
}
|
|
124
|
-
}
|
|
120
|
+
}
|
|
121
|
+
await ctx.session.hook('context', shapeRequest)
|
|
122
|
+
await ctx.session.hook('compaction', shapeRequest)
|
|
123
|
+
await ctx.session.hook('generate', shapeRequest)
|
|
124
|
+
// On Windows the shell is often PowerShell or cmd.exe, which cannot run the POSIX
|
|
125
|
+
// prefix, and Git Bash splits a `C:\...` PATH entry on `:`. There shell create.before
|
|
126
|
+
// swaps the prefix for env vars, which every shell inherits (MSYS converts the
|
|
127
|
+
// Windows PATH itself). That hook has no session or tool call, so the prefixed
|
|
128
|
+
// command, unique per tool call, is the key.
|
|
129
|
+
const windowsShells = new Map<string, { command: string; bin: string; env: Record<string, string> }>()
|
|
125
130
|
await ctx.tool.hook('execute.before', async (event) => {
|
|
126
131
|
if (event.tool !== 'shell' || !event.input || typeof event.input !== 'object' || Array.isArray(event.input)) return
|
|
127
132
|
const info = await marker(event.sessionID)
|
|
128
|
-
const dataDir = info?.
|
|
129
|
-
const lockPort = info?.
|
|
133
|
+
const dataDir = info?.get('dataDir')
|
|
134
|
+
const lockPort = info?.get('lockPort')
|
|
130
135
|
if (typeof dataDir !== 'string' || typeof lockPort !== 'number') return
|
|
131
136
|
if (!('command' in event.input) || typeof event.input.command !== 'string') return
|
|
132
137
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
|
|
133
|
-
|
|
138
|
+
const bin = path.join(dataDir, 'bin')
|
|
139
|
+
const env = { KIMAKI_DATA_DIR: dataDir, KIMAKI_LOCK_PORT: String(lockPort), KIMAKI_TOOL_CALL: `${event.messageID}:${event.id}` }
|
|
140
|
+
const command = event.input.command
|
|
141
|
+
const prefixed = `export PATH=${quote(bin)}:"$PATH" ${Object.entries(env).map(([key, value]) => `${key}=${quote(value)}`).join(' ')}; ${command}`
|
|
142
|
+
event.input.command = prefixed
|
|
143
|
+
if (process.platform === 'win32') windowsShells.set(prefixed, { command, bin, env })
|
|
134
144
|
})
|
|
145
|
+
if (process.platform === 'win32') {
|
|
146
|
+
await ctx.shell.hook('create.before', (event) => {
|
|
147
|
+
const pending = windowsShells.get(event.command)
|
|
148
|
+
if (!pending) return
|
|
149
|
+
windowsShells.delete(event.command)
|
|
150
|
+
event.command = pending.command
|
|
151
|
+
// Spread from process.env, so the key keeps its Windows case (usually `Path`).
|
|
152
|
+
const keys = Object.keys(event.env).filter((key) => key.toUpperCase() === 'PATH')
|
|
153
|
+
for (const key of keys.length > 0 ? keys : ['Path']) {
|
|
154
|
+
event.env[key] = [pending.bin, event.env[key]].filter(Boolean).join(path.delimiter)
|
|
155
|
+
}
|
|
156
|
+
Object.assign(event.env, pending.env)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
await registerCoordinatorRoles(ctx)
|
|
160
|
+
const stopWarmup = await registerWarmup(ctx)
|
|
135
161
|
// File edits feed `kimaki session editors` (v1-compatible JSONL index).
|
|
136
162
|
await ctx.tool.hook('execute.after', async (event) => {
|
|
137
163
|
if (event.status !== 'completed' || !fileEditTool(event.tool)) return
|
|
138
|
-
const dataDir = (await marker(event.sessionID))?.
|
|
164
|
+
const dataDir = (await marker(event.sessionID))?.get('dataDir')
|
|
139
165
|
if (typeof dataDir !== 'string') return
|
|
140
166
|
// Plugins stay silent: a failed append only loses one index entry.
|
|
141
167
|
const recorded = await recordToolEdits({ dataDir, directory: ctx.location.directory, sessionId: event.sessionID, tool: event.tool, input: event.input })
|
|
142
168
|
if (recorded instanceof Error) return
|
|
143
169
|
})
|
|
170
|
+
return stopWarmup
|
|
144
171
|
},
|
|
145
172
|
})
|
package/src/plugin.e2e.test.ts
CHANGED
|
@@ -77,7 +77,7 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
|
|
|
77
77
|
--- from: assistant (TestBot)
|
|
78
78
|
-# *using deterministic-provider/deterministic-v2 ⋅ build*
|
|
79
79
|
ok
|
|
80
|
-
-# *
|
|
80
|
+
-# *main ⋅ Ns ⋅ deterministic-v2*"
|
|
81
81
|
`)
|
|
82
82
|
|
|
83
83
|
const written = fs.statSync(shim).mtimeMs
|