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.
Files changed (94) hide show
  1. package/dist/autostart.js +15 -3
  2. package/dist/autostart.js.map +1 -1
  3. package/dist/bot.d.ts +4 -3
  4. package/dist/bot.js.map +1 -1
  5. package/dist/builtin-skills.d.ts +18 -0
  6. package/dist/builtin-skills.js +51 -0
  7. package/dist/builtin-skills.js.map +1 -0
  8. package/dist/builtin-skills.test.d.ts +1 -0
  9. package/dist/builtin-skills.test.js +28 -0
  10. package/dist/builtin-skills.test.js.map +1 -0
  11. package/dist/cli/bot.js +7 -0
  12. package/dist/cli/bot.js.map +1 -1
  13. package/dist/cli/discord.js +1 -1
  14. package/dist/cli/discord.js.map +1 -1
  15. package/dist/cli/session.js +17 -53
  16. package/dist/cli/session.js.map +1 -1
  17. package/dist/cli-discord.e2e.test.js +19 -1
  18. package/dist/cli-discord.e2e.test.js.map +1 -1
  19. package/dist/cli-lock.e2e.test.js +6 -6
  20. package/dist/cli-read.e2e.test.js +8 -3
  21. package/dist/cli-read.e2e.test.js.map +1 -1
  22. package/dist/commands/preference-commands.js +17 -3
  23. package/dist/commands/preference-commands.js.map +1 -1
  24. package/dist/commands/worktree-commands.d.ts +1 -1
  25. package/dist/effects.js +30 -6
  26. package/dist/effects.js.map +1 -1
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +1 -1
  29. package/dist/errors.js.map +1 -1
  30. package/dist/event-loop.d.ts +2 -1
  31. package/dist/event-loop.js +5 -1
  32. package/dist/event-loop.js.map +1 -1
  33. package/dist/lock-routes.d.ts +15 -15
  34. package/dist/login.e2e.test.js +2 -2
  35. package/dist/main.d.ts +1 -0
  36. package/dist/main.js +222 -151
  37. package/dist/main.js.map +1 -1
  38. package/dist/opencode-server.js +15 -11
  39. package/dist/opencode-server.js.map +1 -1
  40. package/dist/opencode-server.test.js +1 -1
  41. package/dist/opencode-server.test.js.map +1 -1
  42. package/dist/plugin/index.js +19 -1
  43. package/dist/plugin/index.js.map +1 -1
  44. package/dist/plugin/read-media.d.ts +23 -0
  45. package/dist/plugin/read-media.js +72 -0
  46. package/dist/plugin/read-media.js.map +1 -0
  47. package/dist/plugin.e2e.test.js +6 -1
  48. package/dist/plugin.e2e.test.js.map +1 -1
  49. package/dist/prompt.js +10 -11
  50. package/dist/prompt.js.map +1 -1
  51. package/dist/session-events.d.ts +28 -0
  52. package/dist/session-events.js +70 -0
  53. package/dist/session-events.js.map +1 -1
  54. package/dist/sessions.d.ts +5 -5
  55. package/dist/sessions.js +3 -2
  56. package/dist/sessions.js.map +1 -1
  57. package/dist/slash-commands.js +3 -2
  58. package/dist/slash-commands.js.map +1 -1
  59. package/dist/system-prompt.d.ts +13 -2
  60. package/dist/system-prompt.js +41 -64
  61. package/dist/system-prompt.js.map +1 -1
  62. package/dist/voice-attachments.e2e.test.js +6 -3
  63. package/dist/voice-attachments.e2e.test.js.map +1 -1
  64. package/package.json +7 -6
  65. package/skills/kimaki-debug/SKILL.md +44 -0
  66. package/skills/kimaki-tunnel/SKILL.md +34 -0
  67. package/src/autostart.ts +15 -3
  68. package/src/bot.ts +2 -0
  69. package/src/builtin-skills.test.ts +29 -0
  70. package/src/builtin-skills.ts +59 -0
  71. package/src/cli/bot.ts +6 -0
  72. package/src/cli/discord.ts +1 -1
  73. package/src/cli/session.ts +14 -47
  74. package/src/cli-discord.e2e.test.ts +18 -1
  75. package/src/cli-lock.e2e.test.ts +6 -6
  76. package/src/cli-read.e2e.test.ts +8 -3
  77. package/src/cli-sessions.e2e.test.ts +2 -4
  78. package/src/commands/preference-commands.ts +19 -6
  79. package/src/effects.ts +40 -6
  80. package/src/errors.ts +1 -1
  81. package/src/event-loop.ts +9 -0
  82. package/src/login.e2e.test.ts +2 -2
  83. package/src/main.ts +10 -2
  84. package/src/opencode-server.test.ts +1 -1
  85. package/src/opencode-server.ts +12 -9
  86. package/src/plugin/index.ts +20 -1
  87. package/src/plugin.e2e.test.ts +6 -1
  88. package/src/prompt.ts +10 -10
  89. package/src/session-events.ts +83 -0
  90. package/src/sessions.ts +3 -2
  91. package/src/slash-commands.ts +3 -2
  92. package/src/system-prompt.ts +43 -64
  93. package/src/voice-attachments.e2e.test.ts +6 -3
  94. 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 [models, providers] = await Promise.all([
197
- oc(bot, 'model.list', (client) => client.model.list({ location })),
198
- oc(bot, 'provider.list', (client) => client.provider.list({ location })),
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:** OpenCode default'
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 { type Client, type MessageCreateOptions, type MessageFlags, type SendableChannels } from 'discord.js'
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)
@@ -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:** OpenCode default
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:** OpenCode default
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 eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded })
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
  })
@@ -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; `kimaki-anthropic` (provider auth) and
151
- // `kimaki-session-id` (session ID in the system prompt) apply to every session.
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}: opencode.json already loads this plugin`)
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) }
@@ -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
@@ -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('active')
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 { localFilesBlock, type TurnContext } from './system-prompt.ts'
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, localPaths } = await splitPromptFiles(files)
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, localFiles: localPaths }
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 localPaths: string[] = []
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 also get the path: the extension cannot tell text from binary.
126
- if (tooBig || !inline) localPaths.push(local)
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, localPaths }
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, localPaths } = await splitPromptFiles(input.files)
303
- const block = localFilesBlock(localPaths)
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({
@@ -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
  )
@@ -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