kimaki 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autostart.js +15 -3
- package/dist/autostart.js.map +1 -1
- package/dist/bot.d.ts +4 -3
- package/dist/bot.js.map +1 -1
- package/dist/builtin-skills.d.ts +18 -0
- package/dist/builtin-skills.js +51 -0
- package/dist/builtin-skills.js.map +1 -0
- package/dist/builtin-skills.test.d.ts +1 -0
- package/dist/builtin-skills.test.js +28 -0
- package/dist/builtin-skills.test.js.map +1 -0
- package/dist/cli/bot.js +7 -0
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/discord.js +1 -1
- package/dist/cli/discord.js.map +1 -1
- package/dist/cli/session.js +17 -53
- package/dist/cli/session.js.map +1 -1
- package/dist/cli-discord.e2e.test.js +19 -1
- package/dist/cli-discord.e2e.test.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +6 -6
- package/dist/cli-read.e2e.test.js +8 -3
- package/dist/cli-read.e2e.test.js.map +1 -1
- package/dist/commands/preference-commands.js +17 -3
- package/dist/commands/preference-commands.js.map +1 -1
- package/dist/commands/worktree-commands.d.ts +1 -1
- package/dist/effects.js +30 -6
- package/dist/effects.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/event-loop.d.ts +2 -1
- package/dist/event-loop.js +5 -1
- package/dist/event-loop.js.map +1 -1
- package/dist/lock-routes.d.ts +15 -15
- package/dist/login.e2e.test.js +2 -2
- package/dist/main.d.ts +1 -0
- package/dist/main.js +222 -151
- package/dist/main.js.map +1 -1
- package/dist/opencode-server.js +15 -11
- package/dist/opencode-server.js.map +1 -1
- package/dist/opencode-server.test.js +1 -1
- package/dist/opencode-server.test.js.map +1 -1
- package/dist/plugin/index.js +19 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/read-media.d.ts +23 -0
- package/dist/plugin/read-media.js +72 -0
- package/dist/plugin/read-media.js.map +1 -0
- package/dist/plugin.e2e.test.js +6 -1
- package/dist/plugin.e2e.test.js.map +1 -1
- package/dist/prompt.js +10 -11
- package/dist/prompt.js.map +1 -1
- package/dist/session-events.d.ts +28 -0
- package/dist/session-events.js +70 -0
- package/dist/session-events.js.map +1 -1
- package/dist/sessions.d.ts +5 -5
- package/dist/sessions.js +3 -2
- package/dist/sessions.js.map +1 -1
- package/dist/slash-commands.js +3 -2
- package/dist/slash-commands.js.map +1 -1
- package/dist/system-prompt.d.ts +13 -2
- package/dist/system-prompt.js +41 -64
- package/dist/system-prompt.js.map +1 -1
- package/dist/voice-attachments.e2e.test.js +6 -3
- package/dist/voice-attachments.e2e.test.js.map +1 -1
- package/package.json +7 -6
- package/skills/kimaki-debug/SKILL.md +44 -0
- package/skills/kimaki-tunnel/SKILL.md +34 -0
- package/src/autostart.ts +15 -3
- package/src/bot.ts +2 -0
- package/src/builtin-skills.test.ts +29 -0
- package/src/builtin-skills.ts +59 -0
- package/src/cli/bot.ts +6 -0
- package/src/cli/discord.ts +1 -1
- package/src/cli/session.ts +14 -47
- package/src/cli-discord.e2e.test.ts +18 -1
- package/src/cli-lock.e2e.test.ts +6 -6
- package/src/cli-read.e2e.test.ts +8 -3
- package/src/cli-sessions.e2e.test.ts +2 -4
- package/src/commands/preference-commands.ts +19 -6
- package/src/effects.ts +40 -6
- package/src/errors.ts +1 -1
- package/src/event-loop.ts +9 -0
- package/src/login.e2e.test.ts +2 -2
- package/src/main.ts +10 -2
- package/src/opencode-server.test.ts +1 -1
- package/src/opencode-server.ts +12 -9
- package/src/plugin/index.ts +20 -1
- package/src/plugin.e2e.test.ts +6 -1
- package/src/prompt.ts +10 -10
- package/src/session-events.ts +83 -0
- package/src/sessions.ts +3 -2
- package/src/slash-commands.ts +3 -2
- package/src/system-prompt.ts +43 -64
- package/src/voice-attachments.e2e.test.ts +6 -3
- package/src/plugin/session-id/index.ts +0 -19
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// keyed by a short hash (custom IDs max 100 chars), dropped after 10 minutes.
|
|
16
16
|
|
|
17
17
|
import crypto from 'node:crypto'
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
18
19
|
import * as orm from 'drizzle-orm'
|
|
19
20
|
import {
|
|
20
21
|
SlashCommandBuilder,
|
|
@@ -139,7 +140,11 @@ async function current(bot: Bot, target: InteractionTarget) {
|
|
|
139
140
|
if (model) return { scope: 'channel' as const, modelScope: 'channel' as const, agent, model }
|
|
140
141
|
const global = await globalModel(bot)
|
|
141
142
|
if (global instanceof Error) return global
|
|
142
|
-
return { scope: 'channel' as const, modelScope: 'global' as const, agent, model: global }
|
|
143
|
+
if (global) return { scope: 'channel' as const, modelScope: 'global' as const, agent, model: global }
|
|
144
|
+
const fallback = await oc(bot, 'model.default', (client) => client.model.default({ location: { directory: target.directory } }))
|
|
145
|
+
if (fallback instanceof Error) return fallback
|
|
146
|
+
const defaultModel = fallback.data ? { providerID: fallback.data.providerID, id: fallback.data.id, variant: null } : null
|
|
147
|
+
return { scope: 'channel' as const, modelScope: 'OpenCode default' as const, agent, model: defaultModel }
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
// The model a new session of the channel starts with, like startSession:
|
|
@@ -193,10 +198,18 @@ async function resetButtons(bot: Bot, { target, hash }: { target: InteractionTar
|
|
|
193
198
|
|
|
194
199
|
async function enabledModels(bot: Bot, directory: string) {
|
|
195
200
|
const location = { directory }
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
const load = () =>
|
|
202
|
+
Promise.all([
|
|
203
|
+
oc(bot, 'model.list', (client) => client.model.list({ location })),
|
|
204
|
+
oc(bot, 'provider.list', (client) => client.provider.list({ location })),
|
|
205
|
+
])
|
|
206
|
+
let [models, providers] = await load()
|
|
207
|
+
// TODO: drop the retry once OpenCode awaits location load. model.list and provider.list return
|
|
208
|
+
// empty data on the first call for a cold location (reproduced on v2.0.26 with a fresh directory).
|
|
209
|
+
for (let attempt = 0; attempt < 5 && !(models instanceof Error) && models.data.length === 0; attempt++) {
|
|
210
|
+
await sleep(300)
|
|
211
|
+
;[models, providers] = await load()
|
|
212
|
+
}
|
|
200
213
|
if (models instanceof Error) return models
|
|
201
214
|
if (providers instanceof Error) return providers
|
|
202
215
|
const names = new Map(providers.data.map((provider) => [provider.id, provider.name]))
|
|
@@ -363,7 +376,7 @@ async function renderStep(
|
|
|
363
376
|
const [now, resets] = await Promise.all([current(bot, wizard.target), resetButtons(bot, { target: wizard.target, hash })])
|
|
364
377
|
if (now instanceof Error) return replyError(interaction, now)
|
|
365
378
|
if (resets instanceof Error) return replyError(interaction, resets)
|
|
366
|
-
const currentText = now.model ? `**Current (${now.modelScope}):** \`${modelLabel(now.model)}\`` : '**Current:**
|
|
379
|
+
const currentText = now.model ? `**Current (${now.modelScope}):** \`${modelLabel(now.model)}\`` : '**Current:** none configured'
|
|
367
380
|
return reply(
|
|
368
381
|
`${header}\n${currentText}\nSelect a provider:`,
|
|
369
382
|
providers.map(([id, name]) => {
|
package/src/effects.ts
CHANGED
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
import { execFile } from 'node:child_process'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ComponentType,
|
|
21
|
+
MessageFlags,
|
|
22
|
+
type APIMessageTopLevelComponent,
|
|
23
|
+
type Client,
|
|
24
|
+
type MessageCreateOptions,
|
|
25
|
+
type SendableChannels,
|
|
26
|
+
} from 'discord.js'
|
|
20
27
|
|
|
21
28
|
import { formatFooter, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, type UiMessage } from './format-parts.ts'
|
|
22
29
|
import { createLogger } from './logger.ts'
|
|
@@ -83,6 +90,35 @@ function uploadBatches(files: readonly UploadFile[]): UploadFile[][] {
|
|
|
83
90
|
return batches.map((batch) => batch.files)
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
// Media Gallery takes images and videos; everything else is a File component.
|
|
94
|
+
const GALLERY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.webm', '.mov'])
|
|
95
|
+
|
|
96
|
+
// Plain `files` let the Discord client group images into a mosaic apart from
|
|
97
|
+
// other files. Components V2 place each file explicitly, so the thread shows
|
|
98
|
+
// them in argument order. With several files each one gets a numbered label,
|
|
99
|
+
// so the user and the agent can point at "file 3".
|
|
100
|
+
// https://docs.discord.com/developers/components/reference#media-gallery
|
|
101
|
+
function uploadMessage({ files, first, numbered }: { files: readonly UploadFile[]; first: number; numbered: boolean }) {
|
|
102
|
+
const used = new Set<string>()
|
|
103
|
+
const components: APIMessageTopLevelComponent[] = []
|
|
104
|
+
const attachments = files.map((file, index) => {
|
|
105
|
+
const number = first + index
|
|
106
|
+
// attachment:// needs a unique name Discord keeps as is.
|
|
107
|
+
const safe = file.name.replace(/[^\w.-]/g, '_') || 'file'
|
|
108
|
+
const name = used.has(safe) ? `${number}-${safe}` : safe
|
|
109
|
+
used.add(name)
|
|
110
|
+
if (numbered) components.push({ type: ComponentType.TextDisplay, content: `-# ${number}. \`${file.name}\`` })
|
|
111
|
+
const media = { url: `attachment://${name}` }
|
|
112
|
+
components.push(
|
|
113
|
+
GALLERY_EXTENSIONS.has(path.extname(name).toLowerCase())
|
|
114
|
+
? { type: ComponentType.MediaGallery, items: [{ media }] }
|
|
115
|
+
: { type: ComponentType.File, file: media },
|
|
116
|
+
)
|
|
117
|
+
return { attachment: file.path, name }
|
|
118
|
+
})
|
|
119
|
+
return { files: attachments, components, flags: MessageFlags.IsComponentsV2 | SILENT_MESSAGE_FLAGS }
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
// Effects that will never run: an upload still waits for its answer.
|
|
87
123
|
function drop(effects: readonly Effect[]) {
|
|
88
124
|
for (const effect of effects) {
|
|
@@ -141,18 +177,16 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
141
177
|
|
|
142
178
|
// Unlike post(), the error goes back to the agent that uploaded.
|
|
143
179
|
async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error | null> {
|
|
180
|
+
let first = 1
|
|
144
181
|
for (const batch of uploadBatches(files)) {
|
|
145
182
|
if (lifecycle.closed) return new DiscordError({ operation: 'upload (bot stopped)' })
|
|
146
183
|
const channel = await sendableChannel(threadId)
|
|
147
184
|
if (!channel) return new DiscordError({ operation: `upload to thread ${threadId}` })
|
|
148
185
|
const sent = await channel
|
|
149
|
-
.send({
|
|
150
|
-
files: batch.map((file) => ({ attachment: file.path, name: file.name })),
|
|
151
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
152
|
-
allowedMentions: { parse: [] },
|
|
153
|
-
})
|
|
186
|
+
.send({ ...uploadMessage({ files: batch, first, numbered: files.length > 1 }), allowedMentions: { parse: [] } })
|
|
154
187
|
.catch((cause: Error) => new DiscordError({ operation: `upload of ${batch.map((file) => file.name).join(', ')} (${cause.message})`, cause }))
|
|
155
188
|
if (sent instanceof Error) return sent
|
|
189
|
+
first += batch.length
|
|
156
190
|
}
|
|
157
191
|
return null
|
|
158
192
|
}
|
package/src/errors.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class OpenCodeMissingError extends errore.createTaggedError({
|
|
|
20
20
|
// Both install commands put OpenCode 2 at `opencode`.
|
|
21
21
|
export class OpenCodeV1Error extends errore.createTaggedError({
|
|
22
22
|
name: 'OpenCodeV1Error',
|
|
23
|
-
message: 'Kimaki now requires OpenCode 2, but $binary is OpenCode $version. Install OpenCode 2 with: $install (or: npm i -g @opencode/cli). Then run kimaki again',
|
|
23
|
+
message: 'Kimaki now requires OpenCode 2, but $binary is OpenCode $version. Install OpenCode 2 with: $install (or: npm i -g @opencode/cli). Then run kimaki again. To keep using OpenCode 1, run the older Kimaki instead: npx kimaki@0',
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
26
26
|
export class OpenCodeVersionError extends errore.createTaggedError({
|
package/src/event-loop.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function createEventLoop({
|
|
|
68
68
|
recorder,
|
|
69
69
|
analytics,
|
|
70
70
|
onRunEnded,
|
|
71
|
+
onTranscriptDue,
|
|
71
72
|
}: {
|
|
72
73
|
store: BotStore
|
|
73
74
|
db: KimakiDb
|
|
@@ -77,6 +78,8 @@ export function createEventLoop({
|
|
|
77
78
|
analytics: Analytics
|
|
78
79
|
// Every finished or failed run of a thread's root session (voice call notifications).
|
|
79
80
|
onRunEnded: (run: RunEnded) => void
|
|
81
|
+
// A root session stopped running (also after an interrupt): its transcript file is stale.
|
|
82
|
+
onTranscriptDue: (sessionId: string) => void
|
|
80
83
|
}) {
|
|
81
84
|
// The client of the current connection, set before hydration starts.
|
|
82
85
|
const connection: { client: OpenCodeClient | null } = { client: null }
|
|
@@ -159,6 +162,12 @@ export function createEventLoop({
|
|
|
159
162
|
if ((event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed') && store.getState().roots[threadId] === event.data.sessionID) {
|
|
160
163
|
onRunEnded({ threadId, sessionId: event.data.sessionID, error: event.type === 'session.execution.failed' ? event.data.error.message : null })
|
|
161
164
|
}
|
|
165
|
+
if (
|
|
166
|
+
(event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed' || event.type === 'session.execution.interrupted') &&
|
|
167
|
+
store.getState().roots[threadId] === event.data.sessionID
|
|
168
|
+
) {
|
|
169
|
+
onTranscriptDue(event.data.sessionID)
|
|
170
|
+
}
|
|
162
171
|
if (store.getState().threads[threadId] && !coldThreads.has(threadId)) return fold(threadId, event)
|
|
163
172
|
hold(coldThreads, threadId, event)
|
|
164
173
|
void loadView(threadId)
|
package/src/login.e2e.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ test('/login API key stores an OpenCode credential and makes its provider availa
|
|
|
40
40
|
Connect OpenAI
|
|
41
41
|
Connected OpenAI. Use /model to select a model.
|
|
42
42
|
**Set Model Preference**
|
|
43
|
-
**Current
|
|
43
|
+
**Current (OpenCode default):** \`deterministic-provider/deterministic-v2\`
|
|
44
44
|
Select a provider:"
|
|
45
45
|
`)
|
|
46
46
|
expect(models.select.options.some((option) => option.value === 'openai')).toBe(true)
|
|
@@ -75,7 +75,7 @@ test('/login OAuth code uses native attempts and can activate a stored credentia
|
|
|
75
75
|
Connect OpenAI
|
|
76
76
|
Connected OpenAI. Use /model to select a model.
|
|
77
77
|
**Set Model Preference**
|
|
78
|
-
**Current
|
|
78
|
+
**Current (OpenCode default):** \`deterministic-provider/deterministic-v2\`
|
|
79
79
|
Select a provider:
|
|
80
80
|
http://127.0.0.1/authorize
|
|
81
81
|
Enter the test authorization code.
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { createLogger } from './logger.ts'
|
|
|
20
20
|
import { installPluginShim, watchOpencode, type OpencodeEndpoint } from './opencode-server.ts'
|
|
21
21
|
import { countUserProjects } from './project.ts'
|
|
22
22
|
import { createScheduler, systemClock, type Clock } from './scheduler.ts'
|
|
23
|
-
import { createEventRecorder } from './session-events.ts'
|
|
23
|
+
import { createEventRecorder, syncSessionTranscript } from './session-events.ts'
|
|
24
24
|
import { createPluginWait, refreshCliContext } from './sessions.ts'
|
|
25
25
|
import { createInteractionRegistry, registerSlashCommands } from './slash-commands.ts'
|
|
26
26
|
import { createSleepLock } from './sleeps.ts'
|
|
@@ -65,6 +65,8 @@ export type StartBotOptions = {
|
|
|
65
65
|
// How often due tasks and wakes run; null: never (tests call scheduler.runDueTasks).
|
|
66
66
|
schedulerIntervalMs?: number | null
|
|
67
67
|
autoWorktrees?: boolean
|
|
68
|
+
// Built-in skill ids denied in new sessions (builtin-skills.ts).
|
|
69
|
+
disabledSkills?: readonly string[]
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export type BotHandle = Bot & {
|
|
@@ -138,7 +140,12 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
138
140
|
})
|
|
139
141
|
cleanup.defer(() => effects.stop())
|
|
140
142
|
const voiceCalls = createVoiceCalls({ opencodeConfigDir: options.opencodeConfigDir })
|
|
141
|
-
const
|
|
143
|
+
const onTranscriptDue = (sessionId: string) => {
|
|
144
|
+
void oc({ opencode }, 'sync transcript', (client) => syncSessionTranscript({ client, dataDir: options.dataDir, sessionId })).then((result) => {
|
|
145
|
+
if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
|
|
142
149
|
const loaded = await eventLoop.load()
|
|
143
150
|
if (loaded instanceof Error) return loaded
|
|
144
151
|
// Before the service is used, so a service started by ensure() loads it at once.
|
|
@@ -175,6 +182,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
175
182
|
transcriptionBaseUrls: options.transcriptionBaseUrls ?? {},
|
|
176
183
|
realtimeBaseUrls: options.realtimeBaseUrls ?? {},
|
|
177
184
|
autoWorktrees: options.autoWorktrees ?? false,
|
|
185
|
+
disabledSkills: options.disabledSkills ?? [],
|
|
178
186
|
features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
|
|
179
187
|
}
|
|
180
188
|
const scheduler = createScheduler({
|
|
@@ -18,7 +18,7 @@ test('supported binary wins over OpenCode 1, only OpenCode 1 is an error, nothin
|
|
|
18
18
|
|
|
19
19
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v2, v1] })).toBe(v2)
|
|
20
20
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1, v2] })).toBe(v2)
|
|
21
|
-
expect(message(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1] }))).toMatchInlineSnapshot(`"Kimaki now requires OpenCode 2, but <dir>/opencode is OpenCode 1.15.5. Install OpenCode 2 with: curl -fsSL https://opencode.ai/v2/install | bash (or: npm i -g @opencode/cli). Then run kimaki again"`)
|
|
21
|
+
expect(message(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1] }))).toMatchInlineSnapshot(`"Kimaki now requires OpenCode 2, but <dir>/opencode is OpenCode 1.15.5. Install OpenCode 2 with: curl -fsSL https://opencode.ai/v2/install | bash (or: npm i -g @opencode/cli). Then run kimaki again. To keep using OpenCode 1, run the older Kimaki instead: npx kimaki@0"`)
|
|
22
22
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2'] })).toBe(null)
|
|
23
23
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
24
24
|
})
|
package/src/opencode-server.ts
CHANGED
|
@@ -147,14 +147,16 @@ export function opencodeDbFile(): string {
|
|
|
147
147
|
// through its index module. Always dist, also when the bot runs from src: OpenCode
|
|
148
148
|
// reloads the plugin when its files change, so a src target would reload half
|
|
149
149
|
// edited code on every save. dist only changes on `pnpm build`.
|
|
150
|
-
// `kimaki` acts only on marked sessions
|
|
151
|
-
// `kimaki-
|
|
150
|
+
// `kimaki` acts only on marked sessions (except the session ID line);
|
|
151
|
+
// `kimaki-anthropic` (provider auth) applies to every session.
|
|
152
152
|
const PLUGIN_SHIMS = [
|
|
153
153
|
{ name: 'kimaki', directory: '' },
|
|
154
154
|
{ name: 'kimaki-anthropic', directory: 'anthropic' },
|
|
155
|
-
{ name: 'kimaki-session-id', directory: 'session-id' },
|
|
156
155
|
] as const
|
|
157
156
|
|
|
157
|
+
// Shims of older Kimaki versions, removed on bot start. kimaki-session-id moved into the kimaki plugin.
|
|
158
|
+
const LEGACY_SHIMS = ['kimaki-session-id']
|
|
159
|
+
|
|
158
160
|
export function kimakiPluginEntry(directory: string = ''): string {
|
|
159
161
|
// This module is <package>/src/opencode-server.ts or <package>/dist/opencode-server.js.
|
|
160
162
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
@@ -233,26 +235,27 @@ async function installShim({ configDir, name, entry }: { configDir: string; name
|
|
|
233
235
|
// A shim whose plugin the global config already lists would load one plugin ID
|
|
234
236
|
// twice. OpenCode keeps the first and reports the other as a failed plugin
|
|
235
237
|
// ("Duplicate plugin ID", plugin/supervisor.ts), so the shim is removed instead.
|
|
236
|
-
async function removeShim({ configDir, name }: { configDir: string; name: string }) {
|
|
238
|
+
async function removeShim({ configDir, name, why }: { configDir: string; name: string; why: string }) {
|
|
237
239
|
const directory = path.join(configDir, 'plugins', name)
|
|
238
240
|
if (!fs.existsSync(directory)) return { file: directory, written: false }
|
|
239
241
|
const removed = await fs.promises
|
|
240
242
|
.rm(directory, { recursive: true, force: true })
|
|
241
243
|
.catch((cause) => new ConfigError({ reason: `Cannot remove the Kimaki OpenCode plugin shim ${directory}. Check permissions of ${configDir}`, cause }))
|
|
242
244
|
if (removed instanceof Error) return removed
|
|
243
|
-
logger.log(`removed OpenCode plugin shim ${directory}:
|
|
245
|
+
logger.log(`removed OpenCode plugin shim ${directory}: ${why}`)
|
|
244
246
|
return { file: directory, written: true }
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
export async function installPluginShim({ configDir }: { configDir: string }): Promise<ConfigError | { written: boolean }> {
|
|
248
250
|
const targets = await configuredPluginTargets({ configDir })
|
|
249
|
-
const results = await Promise.all(
|
|
250
|
-
PLUGIN_SHIMS.map(async (shim) => {
|
|
251
|
+
const results = await Promise.all([
|
|
252
|
+
...PLUGIN_SHIMS.map(async (shim) => {
|
|
251
253
|
const configured = await Promise.all(targets.map((target) => isKimakiPluginDirectory({ target, directory: shim.directory })))
|
|
252
|
-
if (configured.includes(true)) return removeShim({ configDir, name: shim.name })
|
|
254
|
+
if (configured.includes(true)) return removeShim({ configDir, name: shim.name, why: 'opencode.json already loads this plugin' })
|
|
253
255
|
return installShim({ configDir, name: shim.name, entry: kimakiPluginEntry(shim.directory) })
|
|
254
256
|
}),
|
|
255
|
-
|
|
257
|
+
...LEGACY_SHIMS.map((name) => removeShim({ configDir, name, why: 'replaced by the kimaki plugin' })),
|
|
258
|
+
])
|
|
256
259
|
const failed = results.find((result) => result instanceof Error)
|
|
257
260
|
if (failed instanceof Error) return failed
|
|
258
261
|
return { written: results.some((result) => !(result instanceof Error) && result.written) }
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import { Plugin } from '@opencode/plugin'
|
|
1
|
+
import { Plugin, Skill } from '@opencode/plugin'
|
|
2
|
+
import { Schema } from 'effect'
|
|
2
3
|
import { execFile } from 'node:child_process'
|
|
3
4
|
import { promisify } from 'node:util'
|
|
4
5
|
import path from 'node:path'
|
|
5
6
|
|
|
6
7
|
import { autostartBot } from '../autostart.ts'
|
|
8
|
+
import { readBuiltinSkills } from '../builtin-skills.ts'
|
|
7
9
|
import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
|
|
8
10
|
import { turnContext } from '../system-prompt.ts'
|
|
9
11
|
|
|
10
12
|
const exec = promisify(execFile)
|
|
11
13
|
|
|
14
|
+
const decodeSkill = Schema.decodeUnknownSync(Skill.Info)
|
|
15
|
+
|
|
12
16
|
const BRANCH_PREFIX = '[current git branch is '
|
|
13
17
|
|
|
14
18
|
export default Plugin.define({
|
|
15
19
|
id: 'kimaki',
|
|
16
20
|
async setup(ctx) {
|
|
17
21
|
autostartBot()
|
|
22
|
+
// Skills are per location, not per session, so OpenCode lists them in every
|
|
23
|
+
// session, TUI included. Descriptions say they are for Kimaki sessions only;
|
|
24
|
+
// the bot denies disabled ones per session (--disable-skills). A failed read
|
|
25
|
+
// only loses the skills.
|
|
26
|
+
const skills = await readBuiltinSkills()
|
|
27
|
+
if (!(skills instanceof Error)) {
|
|
28
|
+
await ctx.skill.transform((editor) => {
|
|
29
|
+
for (const skill of skills) {
|
|
30
|
+
editor.add(decodeSkill(skill))
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
18
34
|
async function marker(sessionID: string) {
|
|
19
35
|
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
20
36
|
const value: unknown = session?.metadata?.['kimaki']
|
|
@@ -43,6 +59,9 @@ export default Plugin.define({
|
|
|
43
59
|
await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
|
|
44
60
|
})
|
|
45
61
|
await ctx.session.hook('context', async (event) => {
|
|
62
|
+
// The only prompt change for every session, TUI included. The ID never
|
|
63
|
+
// changes in a session, so the cached prompt prefix stays stable.
|
|
64
|
+
event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
|
|
46
65
|
if (!(await marker(event.sessionID))) return
|
|
47
66
|
// The <discord-user> context of each prompt, built from its metadata, so the
|
|
48
67
|
// stored text stays clean for the TUI and other clients. Same metadata, same
|
package/src/plugin.e2e.test.ts
CHANGED
|
@@ -52,8 +52,13 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
|
|
|
52
52
|
}
|
|
53
53
|
const kimakiStatus = () => pluginStatus('kimaki')
|
|
54
54
|
expect(await kimakiStatus()).toBe('missing')
|
|
55
|
+
// Shim of Kimaki 1.0.0; the session ID line now lives in the kimaki plugin.
|
|
56
|
+
const legacyShim = path.join(server.configDir, 'plugins', 'kimaki-session-id')
|
|
57
|
+
fs.mkdirSync(legacyShim, { recursive: true })
|
|
58
|
+
fs.writeFileSync(path.join(legacyShim, 'index.js'), 'export default undefined\n')
|
|
55
59
|
|
|
56
60
|
bot = await startTestBot({ dataDir, twin, server })
|
|
61
|
+
expect(fs.existsSync(legacyShim)).toBe(false)
|
|
57
62
|
expect(fs.readFileSync(shim, 'utf8').replace(/file:\/\/\S+\/dist\//, 'file:///<kimaki>/dist/')).toMatchInlineSnapshot(`
|
|
58
63
|
"// Generated by Kimaki on bot start. Loads the plugin of the running Kimaki install.
|
|
59
64
|
export { default } from "file:///<kimaki>/dist/plugin/index.js"
|
|
@@ -65,7 +70,7 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
|
|
|
65
70
|
await waitForFooter({ discord: twin.discord, threadId: thread.id })
|
|
66
71
|
expect(await kimakiStatus()).toBe('active')
|
|
67
72
|
expect(await pluginStatus('kimaki-anthropic')).toBe('active')
|
|
68
|
-
expect(await pluginStatus('kimaki-session-id')).toBe('
|
|
73
|
+
expect(await pluginStatus('kimaki-session-id')).toBe('missing')
|
|
69
74
|
expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
70
75
|
"--- from: user (tommy)
|
|
71
76
|
plugin shim turn
|
package/src/prompt.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { allMessages } from './session-events.ts'
|
|
|
44
44
|
import { ensureSessionMarker, forkBtw, startSession, switchAgent, threadProject } from './sessions.ts'
|
|
45
45
|
import { cancelSleep } from './sleeps.ts'
|
|
46
46
|
import type { ScheduledRun } from './system-prompt.ts'
|
|
47
|
-
import {
|
|
47
|
+
import { attachmentsBlock, type PromptAttachment, type TurnContext } from './system-prompt.ts'
|
|
48
48
|
import { isBusy, type UploadFile } from './thread-reducer.ts'
|
|
49
49
|
import { resolveWorkingDirectory } from './worktrees.ts'
|
|
50
50
|
|
|
@@ -88,10 +88,10 @@ export async function prompt(
|
|
|
88
88
|
echo?: string
|
|
89
89
|
},
|
|
90
90
|
): Promise<OpenCodeUnavailableError | OpenCodeError | void> {
|
|
91
|
-
const { attached,
|
|
91
|
+
const { attached, attachments } = await splitPromptFiles(files)
|
|
92
92
|
// The text stays as the user wrote it. The plugin renders the <discord-user>
|
|
93
93
|
// context from this metadata into the model request only (turnContext()).
|
|
94
|
-
const discord: TurnContext = { userId: author.id, username: author.username, messageId, threadId, threadName,
|
|
94
|
+
const discord: TurnContext = { userId: author.id, username: author.username, messageId, threadId, threadName, attachments }
|
|
95
95
|
const result = await oc(bot, 'session.prompt', (client) =>
|
|
96
96
|
client.session.prompt({
|
|
97
97
|
sessionID: sessionId,
|
|
@@ -113,19 +113,19 @@ export async function prompt(
|
|
|
113
113
|
const OPENCODE_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
114
114
|
const INLINE_MEDIA_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf'])
|
|
115
115
|
|
|
116
|
+
// `attachments` keeps every file in the user's order, so "the second image" resolves.
|
|
116
117
|
async function splitPromptFiles(files: readonly PromptFile[]) {
|
|
117
118
|
const attached: Array<{ uri: string; name: string }> = []
|
|
118
|
-
const
|
|
119
|
+
const attachments: PromptAttachment[] = []
|
|
119
120
|
for (const file of files) {
|
|
120
121
|
const local = promptFilePath(file)
|
|
121
122
|
const stat = await fs.promises.stat(local).catch(() => null)
|
|
122
123
|
const tooBig = stat !== null && stat.size > OPENCODE_MAX_ATTACHMENT_BYTES
|
|
123
|
-
const inline = INLINE_MEDIA_EXTENSIONS.has(path.extname(file.name).toLowerCase())
|
|
124
124
|
if (!tooBig) attached.push({ uri: file.uri, name: file.name })
|
|
125
|
-
// Text files
|
|
126
|
-
|
|
125
|
+
// Text files count as not inline: the extension cannot tell text from binary.
|
|
126
|
+
attachments.push({ name: file.name, path: local, inline: !tooBig && INLINE_MEDIA_EXTENSIONS.has(path.extname(file.name).toLowerCase()) })
|
|
127
127
|
}
|
|
128
|
-
return { attached,
|
|
128
|
+
return { attached, attachments }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Questions and permissions of the thread (root and children) that wait for the user.
|
|
@@ -299,8 +299,8 @@ async function command(
|
|
|
299
299
|
const interrupted = await interrupt(bot, input.sessionId)
|
|
300
300
|
if (interrupted instanceof Error) return interrupted
|
|
301
301
|
}
|
|
302
|
-
const { attached,
|
|
303
|
-
const block =
|
|
302
|
+
const { attached, attachments } = await splitPromptFiles(input.files)
|
|
303
|
+
const block = attachmentsBlock(attachments)
|
|
304
304
|
bot.eventLoop.dispatch(input.threadId, { type: 'kimaki.command', text: typed, username: input.author.username })
|
|
305
305
|
const result = await oc(bot, 'session.command', (client) =>
|
|
306
306
|
client.session.command({
|
package/src/session-events.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// kimaki session events <sessionId|threadId> print the JSONL
|
|
11
11
|
// kimaki session read <sessionId|threadId> print the messages from OpenCode
|
|
12
|
+
// kimaki session sync write <dataDir>/transcripts/<sessionId>.md for rg
|
|
12
13
|
|
|
14
|
+
import crypto from 'node:crypto'
|
|
13
15
|
import fs from 'node:fs'
|
|
14
16
|
import path from 'node:path'
|
|
15
17
|
import type { OpenCodeClient } from '@opencode/client'
|
|
@@ -106,6 +108,7 @@ export async function resolveSession({
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
|
|
111
|
+
type Session = Awaited<ReturnType<OpenCodeClient['session']['get']>>
|
|
109
112
|
|
|
110
113
|
function renderMessage(message: Message, options: { thinking?: boolean; verbose?: boolean }): string {
|
|
111
114
|
const time = new Date(message.time.created).toISOString()
|
|
@@ -146,10 +149,90 @@ export async function readSessionMarkdown({
|
|
|
146
149
|
if (session instanceof Error) return session
|
|
147
150
|
const messages = await allMessages({ client, sessionId })
|
|
148
151
|
if (messages instanceof Error) return messages
|
|
152
|
+
return renderSession({ session, messages, thinking, verbose })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderSession({ session, messages, thinking, verbose }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean }): string {
|
|
149
156
|
const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}`
|
|
150
157
|
return [title, ...messages.map((message) => renderMessage(message, { thinking, verbose }))].join('\n\n')
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
// Transcripts: <dataDir>/transcripts/<sessionId>.md, the `session read` markdown
|
|
161
|
+
// (no thinking, no tool outputs) so agents can search every session with rg.
|
|
162
|
+
// The file mtime is the session's time.updated: a file at least that new is current.
|
|
163
|
+
export function transcriptsDir({ dataDir }: { dataDir: string }): string {
|
|
164
|
+
return path.join(dataDir, 'transcripts')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function writeTranscript({ client, dataDir, session }: { client: OpenCodeClient; dataDir: string; session: Pick<Session, 'id' | 'title' | 'location'> & { time: Pick<Session['time'], 'updated'> } }): Promise<OpenCodeError | ConfigError | void> {
|
|
168
|
+
const messages = await allMessages({ client, sessionId: session.id })
|
|
169
|
+
if (messages instanceof Error) return messages
|
|
170
|
+
const dir = transcriptsDir({ dataDir })
|
|
171
|
+
const file = path.join(dir, `${session.id}.md`)
|
|
172
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
173
|
+
const written = await (async () => {
|
|
174
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 })
|
|
175
|
+
await fs.promises.writeFile(temp, `${renderSession({ session, messages })}\n`, { mode: 0o600 })
|
|
176
|
+
await fs.promises.utimes(temp, session.time.updated / 1000, session.time.updated / 1000)
|
|
177
|
+
await fs.promises.rename(temp, file)
|
|
178
|
+
})().catch((cause) => new ConfigError({ reason: `Cannot write ${file}`, cause }))
|
|
179
|
+
if (written instanceof Error) await fs.promises.rm(temp, { force: true }).catch((error) => logger.warn(`cannot remove ${temp}`, error))
|
|
180
|
+
return written
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The bot calls this when a run ends, so transcripts of new sessions appear on their own.
|
|
184
|
+
export async function syncSessionTranscript({ client, dataDir, sessionId }: { client: OpenCodeClient; dataDir: string; sessionId: string }): Promise<OpenCodeError | ConfigError | void> {
|
|
185
|
+
const session = await client.session.get({ sessionID: sessionId }).catch((cause) => new OpenCodeError({ operation: 'session.get', cause }))
|
|
186
|
+
if (session instanceof Error) return session
|
|
187
|
+
return writeTranscript({ client, dataDir, session })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type SyncResult = { written: number; skipped: number; failed: number }
|
|
191
|
+
|
|
192
|
+
// Sessions updated in the last `days` days (0 = all time) of every project.
|
|
193
|
+
// Skips sessions whose transcript is current, unless `rebuild`.
|
|
194
|
+
export async function syncTranscripts({
|
|
195
|
+
client,
|
|
196
|
+
dataDir,
|
|
197
|
+
days,
|
|
198
|
+
rebuild,
|
|
199
|
+
}: {
|
|
200
|
+
client: OpenCodeClient
|
|
201
|
+
dataDir: string
|
|
202
|
+
days: number
|
|
203
|
+
rebuild: boolean
|
|
204
|
+
}): Promise<OpenCodeError | SyncResult> {
|
|
205
|
+
const [sessions, active] = await Promise.all([allSessions({ client }), client.session.active().catch((cause) => new OpenCodeError({ operation: 'session.active', cause }))])
|
|
206
|
+
if (sessions instanceof Error) return sessions
|
|
207
|
+
if (active instanceof Error) return active
|
|
208
|
+
const cutoff = days > 0 ? Date.now() - days * 86_400_000 : 0
|
|
209
|
+
const recent = sessions.filter((session) => session.time.updated >= cutoff)
|
|
210
|
+
const result: SyncResult = { written: 0, skipped: 0, failed: 0 }
|
|
211
|
+
const next = { index: 0 }
|
|
212
|
+
const worker = async () => {
|
|
213
|
+
while (next.index < recent.length) {
|
|
214
|
+
const session = recent[next.index++]!
|
|
215
|
+
const file = path.join(transcriptsDir({ dataDir }), `${session.id}.md`)
|
|
216
|
+
const stat = await fs.promises.stat(file).catch(() => null)
|
|
217
|
+
// time.updated may not move when a run ends: the live hook writes busy sessions when they stop.
|
|
218
|
+
if (session.id in active || (!rebuild && stat && stat.mtimeMs >= session.time.updated - 1)) {
|
|
219
|
+
result.skipped++
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const written = await writeTranscript({ client, dataDir, session })
|
|
223
|
+
if (written instanceof Error) {
|
|
224
|
+
result.failed++
|
|
225
|
+
logger.warn(`transcript of ${session.id} failed`, written)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
result.written++
|
|
229
|
+
logger.log(`transcript ${result.written + result.skipped + result.failed}/${recent.length}: ${session.id}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await Promise.all(Array.from({ length: Math.min(8, recent.length) }, worker))
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
|
|
153
236
|
// Cursor pagination of message.list / session.list: every page, in order.
|
|
154
237
|
export async function collectPages<T>({
|
|
155
238
|
operation,
|
package/src/sessions.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { prompt, runInSession } from './prompt.ts'
|
|
|
33
33
|
import { formatEcho } from './queue.ts'
|
|
34
34
|
import type { Route } from './routes.ts'
|
|
35
35
|
import * as schema from './schema.ts'
|
|
36
|
+
import { disabledSkillRules } from './builtin-skills.ts'
|
|
36
37
|
import { baseInstructions, INSTRUCTION_KEY, type ScheduledRun } from './system-prompt.ts'
|
|
37
38
|
import { createWorktree, resolveWorkingDirectory } from './worktrees.ts'
|
|
38
39
|
|
|
@@ -423,7 +424,7 @@ export async function startSession(
|
|
|
423
424
|
location: { directory },
|
|
424
425
|
...(model && { model }),
|
|
425
426
|
...(agent && { agent }),
|
|
426
|
-
permissions: [NO_QUESTION_TOOL, ...(permissions ?? [])],
|
|
427
|
+
permissions: [NO_QUESTION_TOOL, ...disabledSkillRules(bot.disabledSkills), ...(permissions ?? [])],
|
|
427
428
|
metadata: { kimaki },
|
|
428
429
|
}),
|
|
429
430
|
)
|
|
@@ -533,7 +534,7 @@ async function adoptSession(
|
|
|
533
534
|
const kimaki = { ...marker, source: scheduledTask ? 'task' : 'discord', ...cliContext(bot), threadId: thread.id, channelId: channel.id }
|
|
534
535
|
// A session with its own question rule (a fork, or --permission) keeps it.
|
|
535
536
|
const rules = session.permissions ?? []
|
|
536
|
-
const permissions = rules.some((rule) => rule.action === 'question') ? rules : [NO_QUESTION_TOOL, ...rules]
|
|
537
|
+
const permissions = rules.some((rule) => rule.action === 'question') ? rules : [NO_QUESTION_TOOL, ...disabledSkillRules(bot.disabledSkills), ...rules]
|
|
537
538
|
const marked = await oc(bot, 'session.update', (client) =>
|
|
538
539
|
client.session.update({ sessionID: sessionId, metadata: { ...session.metadata, kimaki }, permissions }),
|
|
539
540
|
)
|
package/src/slash-commands.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// Discord allows 100 commands per guild; skills are cut first.
|
|
34
34
|
|
|
35
|
+
import { isBuiltinSkill } from './builtin-skills.ts'
|
|
35
36
|
import {
|
|
36
37
|
Events,
|
|
37
38
|
MessageFlags,
|
|
@@ -175,7 +176,7 @@ async function readCatalog(bot: Pick<Bot, 'opencode'>, directory: string): Promi
|
|
|
175
176
|
if (agents instanceof Error) return agents
|
|
176
177
|
if (commands instanceof Error) return commands
|
|
177
178
|
if (skills instanceof Error) return skills
|
|
178
|
-
return { agents: agents.data, commands: commands.data, skills: skills.data }
|
|
179
|
+
return { agents: agents.data, commands: commands.data, skills: skills.data.filter((skill) => !isBuiltinSkill(skill.id)) }
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
// Autocomplete keeps the whole project catalog reachable beyond Discord's shortcut limit.
|
|
@@ -187,7 +188,7 @@ function catalogRoutes(): InteractionRoutes {
|
|
|
187
188
|
if (kind === 'skill') {
|
|
188
189
|
const skills = await oc(bot, 'skill.list', (client) => client.skill.list({ location }))
|
|
189
190
|
if (skills instanceof Error) return skills
|
|
190
|
-
return skills.data.map((skill) => ({ name: skill.id, description: skill.description }))
|
|
191
|
+
return skills.data.filter((skill) => !isBuiltinSkill(skill.id)).map((skill) => ({ name: skill.id, description: skill.description }))
|
|
191
192
|
}
|
|
192
193
|
const list = await oc(bot, 'command.list', (client) => client.command.list({ location }))
|
|
193
194
|
if (list instanceof Error) return list
|