kimaki 1.0.0 → 1.2.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 (108) hide show
  1. package/dist/autostart.js +15 -3
  2. package/dist/autostart.js.map +1 -1
  3. package/dist/bot.d.ts +7 -5
  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 +10 -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 +7 -6
  20. package/dist/cli-lock.e2e.test.js.map +1 -1
  21. package/dist/cli-read.e2e.test.js +8 -3
  22. package/dist/cli-read.e2e.test.js.map +1 -1
  23. package/dist/cli-sessions.e2e.test.js +2 -4
  24. package/dist/cli-sessions.e2e.test.js.map +1 -1
  25. package/dist/commands/login-commands.d.ts +1 -1
  26. package/dist/commands/preference-commands.d.ts +1 -1
  27. package/dist/commands/preference-commands.js +17 -3
  28. package/dist/commands/preference-commands.js.map +1 -1
  29. package/dist/commands/worktree-commands.d.ts +1 -1
  30. package/dist/effects.js +30 -6
  31. package/dist/effects.js.map +1 -1
  32. package/dist/errors.d.ts +1 -1
  33. package/dist/errors.js +1 -1
  34. package/dist/errors.js.map +1 -1
  35. package/dist/event-loop.d.ts +2 -1
  36. package/dist/event-loop.js +5 -1
  37. package/dist/event-loop.js.map +1 -1
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/ingress.js +14 -11
  40. package/dist/ingress.js.map +1 -1
  41. package/dist/lock-routes.d.ts +12 -12
  42. package/dist/login.e2e.test.js +2 -2
  43. package/dist/main.d.ts +2 -0
  44. package/dist/main.js +223 -151
  45. package/dist/main.js.map +1 -1
  46. package/dist/opencode-server.js +15 -11
  47. package/dist/opencode-server.js.map +1 -1
  48. package/dist/opencode-server.test.js +1 -1
  49. package/dist/opencode-server.test.js.map +1 -1
  50. package/dist/plugin/index.js +19 -1
  51. package/dist/plugin/index.js.map +1 -1
  52. package/dist/plugin/read-media.d.ts +23 -0
  53. package/dist/plugin/read-media.js +72 -0
  54. package/dist/plugin/read-media.js.map +1 -0
  55. package/dist/plugin.e2e.test.js +6 -1
  56. package/dist/plugin.e2e.test.js.map +1 -1
  57. package/dist/prompt.d.ts +6 -6
  58. package/dist/prompt.js +10 -11
  59. package/dist/prompt.js.map +1 -1
  60. package/dist/scheduler.d.ts +1 -1
  61. package/dist/session-events.d.ts +29 -1
  62. package/dist/session-events.js +70 -0
  63. package/dist/session-events.js.map +1 -1
  64. package/dist/sessions.d.ts +10 -10
  65. package/dist/sessions.js +3 -2
  66. package/dist/sessions.js.map +1 -1
  67. package/dist/slash-commands.js +4 -3
  68. package/dist/slash-commands.js.map +1 -1
  69. package/dist/system-prompt.d.ts +13 -2
  70. package/dist/system-prompt.js +41 -64
  71. package/dist/system-prompt.js.map +1 -1
  72. package/dist/voice-attachments.e2e.test.js +6 -3
  73. package/dist/voice-attachments.e2e.test.js.map +1 -1
  74. package/dist/voice-call.js +1 -1
  75. package/dist/voice-call.js.map +1 -1
  76. package/package.json +7 -6
  77. package/skills/kimaki-debug/SKILL.md +44 -0
  78. package/skills/kimaki-tunnel/SKILL.md +34 -0
  79. package/src/autostart.ts +15 -3
  80. package/src/bot.ts +4 -0
  81. package/src/builtin-skills.test.ts +29 -0
  82. package/src/builtin-skills.ts +59 -0
  83. package/src/cli/bot.ts +9 -0
  84. package/src/cli/discord.ts +1 -1
  85. package/src/cli/session.ts +14 -47
  86. package/src/cli-discord.e2e.test.ts +18 -1
  87. package/src/cli-lock.e2e.test.ts +7 -6
  88. package/src/cli-read.e2e.test.ts +8 -3
  89. package/src/cli-sessions.e2e.test.ts +2 -4
  90. package/src/commands/preference-commands.ts +19 -6
  91. package/src/effects.ts +40 -6
  92. package/src/errors.ts +1 -1
  93. package/src/event-loop.ts +9 -0
  94. package/src/ingress.ts +16 -11
  95. package/src/login.e2e.test.ts +2 -2
  96. package/src/main.ts +12 -2
  97. package/src/opencode-server.test.ts +1 -1
  98. package/src/opencode-server.ts +12 -9
  99. package/src/plugin/index.ts +20 -1
  100. package/src/plugin.e2e.test.ts +6 -1
  101. package/src/prompt.ts +10 -10
  102. package/src/session-events.ts +83 -0
  103. package/src/sessions.ts +3 -2
  104. package/src/slash-commands.ts +4 -3
  105. package/src/system-prompt.ts +43 -64
  106. package/src/voice-attachments.e2e.test.ts +6 -3
  107. package/src/voice-call.ts +1 -1
  108. package/src/plugin/session-id/index.ts +0 -19
package/src/ingress.ts CHANGED
@@ -39,9 +39,13 @@ import { isVoiceAttachment, parseVoiceMessage, transcribe, voiceMediaType, type
39
39
 
40
40
  const logger = createLogger('INGRESS')
41
41
 
42
- // Owner, Administrator, Manage Server, or a role named "Kimaki". A role named
43
- // "no-kimaki" always denies. Missing member data fails closed.
44
- export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: string }): Promise<boolean> {
42
+ // Owner, Administrator, Manage Server, or a role named "Kimaki"; with
43
+ // --allow-all-users every member. A role named "no-kimaki" always denies.
44
+ // Missing member data fails closed.
45
+ export async function canUseKimaki(
46
+ bot: Pick<Bot, 'allowAllUsers'>,
47
+ { guild, userId }: { guild: Guild; userId: string },
48
+ ): Promise<boolean> {
45
49
  const member = await guild.members.fetch({ user: userId, force: true }).catch((error: Error) => error)
46
50
  if (member instanceof Error) {
47
51
  // Still denied, but say why: a REST failure must not look like a missing role.
@@ -49,19 +53,20 @@ export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: st
49
53
  return false
50
54
  }
51
55
  if (!(member instanceof GuildMember)) return false
52
- return memberCanUseKimaki(member)
56
+ return memberCanUseKimaki(bot, member)
53
57
  }
54
58
 
55
59
  // MESSAGE_CREATE carries the author's current roles: no REST fetch on the hot path.
56
60
  // Discord omits the member only for webhook and ephemeral messages, which never get here.
57
- function authorCanUseKimaki(message: Message<true>): Promise<boolean> {
58
- if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(message.member))
59
- return canUseKimaki({ guild: message.guild, userId: message.author.id })
61
+ function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean> {
62
+ if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(bot, message.member))
63
+ return canUseKimaki(bot, { guild: message.guild, userId: message.author.id })
60
64
  }
61
65
 
62
- function memberCanUseKimaki(member: GuildMember): boolean {
66
+ function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
63
67
  const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
64
68
  if (roleNames.includes('no-kimaki')) return false
69
+ if (bot.allowAllUsers) return true
65
70
  if (member.guild.ownerId === member.id) return true
66
71
  if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
67
72
  if (member.permissions.has(PermissionFlagsBits.ManageGuild)) return true
@@ -208,7 +213,7 @@ async function handleMessage(bot: Bot, message: Message) {
208
213
  if (!project) return
209
214
  if (project.channel_type === 'voice') {
210
215
  // The text chat of the voice channel talks to the running call.
211
- if (message.author.bot || thread || !(await authorCanUseKimaki(message))) return
216
+ if (message.author.bot || thread || !(await authorCanUseKimaki(bot, message))) return
212
217
  bot.features.voiceCalls.text(bot, {
213
218
  guildId: message.guild.id,
214
219
  channelId,
@@ -221,7 +226,7 @@ async function handleMessage(bot: Bot, message: Message) {
221
226
  if (message.author.id !== bot.discord.user?.id) return
222
227
  return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
223
228
  }
224
- if (!(await authorCanUseKimaki(message))) {
229
+ if (!(await authorCanUseKimaki(bot, message))) {
225
230
  logger.log(`ignoring ${message.author.username}: no Kimaki permission`)
226
231
  return
227
232
  }
@@ -317,7 +322,7 @@ export function registerIngress(bot: Bot) {
317
322
  serialize(message.channelId, async () => {
318
323
  const full = message.partial ? await message.fetch().catch(() => null) : message
319
324
  if (!full?.guild || !queuedItemFor(bot, { threadId: full.channelId, messageId: full.id })) return
320
- if (!(await canUseKimaki({ guild: full.guild, userId: full.author.id }))) return
325
+ if (!(await canUseKimaki(bot, { guild: full.guild, userId: full.author.id }))) return
321
326
  // The re-queued prompt must carry the message's attachments again.
322
327
  const files = await saveAttachments({ dataDir: bot.dataDir, messageId: full.id, attachments: [...full.attachments.values()] })
323
328
  if (files instanceof Error) {
@@ -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,9 @@ 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
+ allowAllUsers?: boolean
69
+ // Built-in skill ids denied in new sessions (builtin-skills.ts).
70
+ disabledSkills?: readonly string[]
68
71
  }
69
72
 
70
73
  export type BotHandle = Bot & {
@@ -138,7 +141,12 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
138
141
  })
139
142
  cleanup.defer(() => effects.stop())
140
143
  const voiceCalls = createVoiceCalls({ opencodeConfigDir: options.opencodeConfigDir })
141
- const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded })
144
+ const onTranscriptDue = (sessionId: string) => {
145
+ void oc({ opencode }, 'sync transcript', (client) => syncSessionTranscript({ client, dataDir: options.dataDir, sessionId })).then((result) => {
146
+ if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
147
+ })
148
+ }
149
+ const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
142
150
  const loaded = await eventLoop.load()
143
151
  if (loaded instanceof Error) return loaded
144
152
  // Before the service is used, so a service started by ensure() loads it at once.
@@ -175,6 +183,8 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
175
183
  transcriptionBaseUrls: options.transcriptionBaseUrls ?? {},
176
184
  realtimeBaseUrls: options.realtimeBaseUrls ?? {},
177
185
  autoWorktrees: options.autoWorktrees ?? false,
186
+ allowAllUsers: options.allowAllUsers ?? false,
187
+ disabledSkills: options.disabledSkills ?? [],
178
188
  features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
179
189
  }
180
190
  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
@@ -460,7 +461,7 @@ export function registerSlashCommands(
460
461
  if (project instanceof Error) return logger.warn(project)
461
462
  if (!project) return
462
463
  const guild = interaction.guild ?? (await discord.guilds.fetch(interaction.guildId).catch(() => null))
463
- if (!guild || !(await canUseKimaki({ guild, userId: interaction.user.id }))) {
464
+ if (!guild || !(await canUseKimaki(bot, { guild, userId: interaction.user.id }))) {
464
465
  if (interaction.isAutocomplete()) return respondChoices(interaction, [])
465
466
  if (interaction.isRepliable()) {
466
467
  await interaction.reply({ content: 'You do not have permission to use Kimaki', flags: MessageFlags.Ephemeral })