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/autostart.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // on every start; the Kimaki OpenCode plugin opens it when no bot answers on
3
3
  // the lock port:
4
4
  //
5
- // kimaki start ──writes──▶ ~/.kimaki/autostart.command (exec node <pkg>/dist/cli.js ...)
5
+ // kimaki start ──writes──▶ ~/.kimaki/autostart.command (exec <bot node> <pkg>/dist/cli.js ...)
6
6
  // ▲
7
7
  // OpenCode loads plugin ──no /health on 29988──▶ open -g autostart.command
8
8
  // (default terminal app, in background)
@@ -37,13 +37,25 @@ export async function writeAutostartScript({ dataDir, args }: { dataDir: string;
37
37
  const file = path.join(dataDir, SCRIPT_NAME)
38
38
  const cli = distCliEntry()
39
39
  if (!fs.existsSync(cli)) return fs.promises.rm(file, { force: true }).catch((cause: Error) => cause)
40
- const command = [process.execPath, cli, '--if-not-running', '--data-dir', dataDir, ...args].map(quote).join(' ')
40
+ const command = [cli, '--if-not-running', '--data-dir', dataDir, ...args].map(quote).join(' ')
41
+ // OpenCode is a compiled Bun binary with no Node inside, so reuse the Node that ran
42
+ // this bot. Fall back to PATH when a version manager removed that Node.
41
43
  // The bot inherits the OpenCode (or Terminal) env: drop overrides that would change its port, token or service.
42
44
  const script = dedent`
43
45
  #!/bin/sh
44
46
  # Generated by Kimaki on bot start. The Kimaki OpenCode plugin opens it when no bot runs.
45
47
  unset KIMAKI_LOCK_PORT KIMAKI_BOT_TOKEN KIMAKI_OPENCODE_SERVICE_FILE KIMAKI_DATA_DIR KIMAKI_SUPERVISED
46
- exec ${command}
48
+ node=${quote(process.execPath)}
49
+ [ -x "$node" ] || node=node
50
+ echo 'Kimaki autostart'
51
+ echo 'OpenCode started and no Kimaki bot answered on port ${DEFAULT_LOCK_PORT}, so the Kimaki'
52
+ echo 'OpenCode plugin opened this window to start the Discord bot.'
53
+ echo 'The bot lets you talk to your OpenCode sessions from Discord.'
54
+ echo 'Close this window or press Ctrl+C to stop the bot.'
55
+ echo "Node: $node"
56
+ echo "Script: $0"
57
+ echo
58
+ exec "$node" ${command}
47
59
  ` + '\n'
48
60
  return fs.promises.writeFile(file, script, { mode: 0o755 }).then(() => fs.promises.chmod(file, 0o755))
49
61
  .catch((cause: Error) => cause)
package/src/bot.ts CHANGED
@@ -65,6 +65,10 @@ export type Bot = {
65
65
  realtimeBaseUrls: RealtimeBaseUrls
66
66
  // Default for channels without a channel_worktrees row.
67
67
  autoWorktrees: boolean
68
+ // --allow-all-users: every guild member can use the bot (canUseKimaki). Admin-only commands stay admin-only.
69
+ allowAllUsers: boolean
70
+ // Built-in skill ids (builtin-skills.ts) denied through the permissions of new sessions.
71
+ disabledSkills: readonly string[]
68
72
  features: BotFeatures
69
73
  }
70
74
 
@@ -0,0 +1,29 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { BUILTIN_SKILL_IDS, disabledSkillRules, parseDisabledSkills, readBuiltinSkills } from './builtin-skills.ts'
4
+
5
+ test('every built-in skill file parses', async () => {
6
+ const skills = await readBuiltinSkills()
7
+ if (skills instanceof Error) throw skills
8
+ expect(skills.map((skill) => skill.id)).toEqual([...BUILTIN_SKILL_IDS])
9
+ for (const skill of skills) {
10
+ expect(skill.description, skill.id).toContain('Only for Kimaki Discord sessions')
11
+ expect(skill.content.startsWith('# '), skill.id).toBe(true)
12
+ }
13
+ })
14
+
15
+ test('--disable-skills accepts all, a list, and rejects unknown ids', () => {
16
+ expect(parseDisabledSkills(undefined)).toEqual([])
17
+ expect(parseDisabledSkills('kimaki-tunnel, kimaki-debug')).toEqual(['kimaki-tunnel', 'kimaki-debug'])
18
+ expect(parseDisabledSkills('all')).toEqual([...BUILTIN_SKILL_IDS])
19
+ expect(parseDisabledSkills('tunnel')).toBeInstanceOf(Error)
20
+ expect(disabledSkillRules(['kimaki-tunnel'])).toMatchInlineSnapshot(`
21
+ [
22
+ {
23
+ "action": "skill",
24
+ "effect": "deny",
25
+ "resource": "kimaki-tunnel",
26
+ },
27
+ ]
28
+ `)
29
+ })
@@ -0,0 +1,59 @@
1
+ // Opt-out skills Kimaki ships in skills/<id>/SKILL.md (debug, tunnel). They are
2
+ // not in the system prompt, which keeps the rest of the CLI reference.
3
+ //
4
+ // plugin (OpenCode process) ──▶ readBuiltinSkills ──▶ ctx.skill.transform(add)
5
+ // bot ──▶ --disable-skills ──▶ session permissions `skill:<id>` deny
6
+ //
7
+ // Plugin files must not pull in discord.js or SQLite: keep this module plain fs.
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ import { ConfigError, FilesystemError } from './errors.ts'
14
+
15
+ export const BUILTIN_SKILL_IDS = ['kimaki-debug', 'kimaki-tunnel'] as const
16
+
17
+ export type BuiltinSkillId = (typeof BUILTIN_SKILL_IDS)[number]
18
+
19
+ export function isBuiltinSkill(id: string): boolean {
20
+ return (BUILTIN_SKILL_IDS as readonly string[]).includes(id)
21
+ }
22
+
23
+ // src/ and dist/ both sit next to skills/.
24
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills')
25
+
26
+ export type BuiltinSkill = { id: string; name: string; description: string; path: string; content: string }
27
+
28
+ function frontmatterValue(frontmatter: string, key: string): string | undefined {
29
+ return new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(frontmatter)?.[1]?.trim()
30
+ }
31
+
32
+ export async function readBuiltinSkills(): Promise<FilesystemError | BuiltinSkill[]> {
33
+ const skills: BuiltinSkill[] = []
34
+ for (const id of BUILTIN_SKILL_IDS) {
35
+ const file = path.join(SKILLS_DIR, id, 'SKILL.md')
36
+ const text = await fs.promises.readFile(file, 'utf8').catch((cause) => new FilesystemError({ operation: `read ${file}`, cause }))
37
+ if (text instanceof Error) return text
38
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text)
39
+ const description = match?.[1] ? frontmatterValue(match[1], 'description') : undefined
40
+ if (!match || !description) return new FilesystemError({ operation: `parse ${file}: missing frontmatter description` })
41
+ // Virtual path: OpenCode has no file to scan for these (same as its /builtin skills).
42
+ skills.push({ id, name: id, description, path: `/kimaki/${id}.md`, content: match[2]!.trim() })
43
+ }
44
+ return skills
45
+ }
46
+
47
+ // `all` or a comma separated list of built-in skill ids.
48
+ export function parseDisabledSkills(value: string | undefined): ConfigError | BuiltinSkillId[] {
49
+ const items = (value ?? '').split(',').map((item) => item.trim()).filter(Boolean)
50
+ if (items.includes('all')) return [...BUILTIN_SKILL_IDS]
51
+ const unknown = items.filter((item) => !isBuiltinSkill(item))
52
+ if (unknown.length > 0) return new ConfigError({ reason: `Unknown built-in skill ${unknown.join(', ')}. Choose from: all, ${BUILTIN_SKILL_IDS.join(', ')}` })
53
+ return items as BuiltinSkillId[]
54
+ }
55
+
56
+ // OpenCode hides a skill from the model and the skill tool when its permission is denied.
57
+ export function disabledSkillRules(disabled: readonly string[]) {
58
+ return disabled.map((id) => ({ action: 'skill', resource: id, effect: 'deny' }) as const)
59
+ }
package/src/cli/bot.ts CHANGED
@@ -11,6 +11,7 @@ import dedent from 'string-dedent'
11
11
  import type { Goke } from 'goke'
12
12
 
13
13
  import { distCliEntry, writeAutostartScript } from '../autostart.ts'
14
+ import { parseDisabledSkills } from '../builtin-skills.ts'
14
15
  import { callBot, DEFAULT_LOCK_PORT, RESTART_EXIT_CODE, startLockServer } from '../lock-server.ts'
15
16
  import { parseDuration } from '../duration.ts'
16
17
  import { createLogger, setLogFile } from '../logger.ts'
@@ -87,7 +88,9 @@ export function registerStartCommand(cli: Goke) {
87
88
  .option('--machine-name <name>', 'Name in this machine\'s category "Kimaki <name>" (default: hostname)')
88
89
  .option('--restart-onboarding', 'Choose credentials again')
89
90
  .option('--worktrees', 'Use a fresh Git worktree for new sessions unless the channel overrides it')
91
+ .option('--allow-all-users', 'Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)')
90
92
  .option('--voice <name>', 'Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it')
93
+ .option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
91
94
  .option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
92
95
  .option('--if-not-running', 'Start only if no bot runs; never stop the running one (used by autostart)')
93
96
  .action(async (options) => {
@@ -124,6 +127,8 @@ export function registerStartCommand(cli: Goke) {
124
127
  }
125
128
  const dataDir = dataDirOrDefault(options.dataDir)
126
129
  const urls = gatewayUrlsFromEnv()
130
+ const disabledSkills = parseDisabledSkills(options.disableSkills)
131
+ if (disabledSkills instanceof Error) fail(disabledSkills)
127
132
  const machine = options.machineName ?? defaultMachineName()
128
133
  // Validated before the lock, so a typo never stops the running bot. null: reset to the default.
129
134
  const voice = (() => {
@@ -212,6 +217,8 @@ export function registerStartCommand(cli: Goke) {
212
217
  opencodeDbFile: opencodeDbFile(),
213
218
  analytics: createAnalytics({ dataDir, botMode: credentials.mode, enabled: !options.noAnalytics }),
214
219
  autoWorktrees: Boolean(options.worktrees),
220
+ allowAllUsers: Boolean(options.allowAllUsers),
221
+ disabledSkills,
215
222
  })
216
223
  if (bot instanceof Error) failStartup(bot)
217
224
  // exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
@@ -239,6 +246,8 @@ export function registerStartCommand(cli: Goke) {
239
246
  if (lock.port === DEFAULT_LOCK_PORT) {
240
247
  const autostart = await writeAutostartScript({ dataDir, args: [
241
248
  ...(options.worktrees ? ['--worktrees'] : []),
249
+ ...(options.allowAllUsers ? ['--allow-all-users'] : []),
250
+ ...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
242
251
  ...(options.noAnalytics ? ['--no-analytics'] : []),
243
252
  ...(options.machineName ? ['--machine-name', options.machineName] : []),
244
253
  ] })
@@ -99,7 +99,7 @@ export function registerDiscordCommands(cli: Goke) {
99
99
  printRows({ json: options.json, rows: users, line: (user) => `${user.id} ${user.username}${user.name ? ` (${user.name})` : ''}` })
100
100
  })
101
101
 
102
- cli.command('upload-to-discord <...files>', 'Attach local files to a session thread or the Kimaki voice channel chat')
102
+ cli.command('upload-to-discord <...files>', 'Attach local files to a session thread or the Kimaki voice channel chat. Pass many files in one call: Discord shows them numbered in argument order')
103
103
  .option('--data-dir <path>', DATA_DIR_HELP)
104
104
  .option('-s, --session <id>', SESSION_HELP)
105
105
  .option('-c, --channel <id>', 'Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)')
@@ -11,7 +11,7 @@ import { wrapJsonSchema, type Goke } from 'goke'
11
11
  import { parseDuration } from '../duration.ts'
12
12
  import { OpenCodeError } from '../errors.ts'
13
13
  import { editorsForFile, loadFileEditEvents } from '../file-edit-log.ts'
14
- import { allMessages, allSessions, readSessionMarkdown, sessionEventsFile } from '../session-events.ts'
14
+ import { allMessages, allSessions, readSessionMarkdown, sessionEventsFile, syncTranscripts, transcriptsDir } from '../session-events.ts'
15
15
  import {
16
16
  action,
17
17
  DATA_DIR_HELP,
@@ -31,7 +31,7 @@ import {
31
31
 
32
32
  const execFileAsync = promisify(execFile)
33
33
 
34
- // list, search, wait, editors, diff, url
34
+ // list, sync, wait, editors, diff, url
35
35
  export function registerSessionQueryCommands(cli: Goke) {
36
36
  cli.command('session list', 'List sessions with native status and token counts')
37
37
  .option('--data-dir <path>', DATA_DIR_HELP)
@@ -65,54 +65,21 @@ export function registerSessionQueryCommands(cli: Goke) {
65
65
  if (options.active && rows.length === 0) process.exitCode = 1
66
66
  })
67
67
 
68
- cli.command('session search <query>', 'Search titles, then real message content')
68
+ cli.command('session sync', 'Write session transcripts to <data-dir>/transcripts for rg; skips current ones')
69
69
  .option('--data-dir <path>', DATA_DIR_HELP)
70
- .option('--project <path>', 'Project (default: current directory)')
71
- .option('-c, --channel <id>', 'Project of this Discord channel')
72
- .option('--all', 'All projects')
73
- .option('--days <n>', 'Recent days (default: 14; 0 = all)')
70
+ .option('--days <n>', 'Sessions updated in the last n days (default: 14)')
71
+ .option('--all', 'All sessions, any age')
72
+ .option('--rebuild', 'Write transcripts again, also current ones')
74
73
  .option('--json', 'Output as JSON')
75
- .action(async (query, options) => {
76
- const client = await readClient()
77
- const directory = options.all ? undefined : await projectDirectory({ project: options.project, channel: options.channel, dataDir: options.dataDir })
78
- const sessions = await allSessions({ client, directory })
79
- if (sessions instanceof Error) fail(sessions)
80
- // `/pattern/flags` is a regular expression, anything else a case-insensitive substring.
81
- const pattern = query.match(/^\/(.*)\/([dgimsuvy]*)$/)
82
- const expression = pattern ? new RegExp(pattern[1]!, pattern[2]) : null
83
- const matches = (text: string) => {
84
- if (!expression) return text.toLowerCase().includes(query.toLowerCase())
85
- expression.lastIndex = 0
86
- return expression.test(text)
87
- }
88
- const days = Number(options.days ?? 14)
74
+ .action(async (options) => {
75
+ const days = options.all ? 0 : Number(options.days ?? 14)
89
76
  if (!Number.isFinite(days) || days < 0) fail(new Error('--days must be a non-negative number'))
90
- const recent = sessions.filter((session) => !days || session.time.updated >= Date.now() - days * 86400000)
91
- // OpenCode has no content search API: scan pages newest first, stop at the first hit.
92
- const hit = async (session: (typeof sessions)[number]) => {
93
- if (matches(session.title ?? '')) return true
94
- const scan = async (cursor: string | undefined): Promise<OpenCodeError | boolean> => {
95
- const page = await client.message.list({ sessionID: session.id, limit: 200, ...(cursor ? { cursor } : { order: 'desc' as const }) })
96
- .catch((cause) => new OpenCodeError({ operation: 'message.list', cause }))
97
- if (page instanceof Error) return page
98
- if (page.data.some((message) => matches(message.type === 'user' ? message.text : JSON.stringify(message)))) return true
99
- return page.cursor.next ? scan(page.cursor.next) : false
100
- }
101
- return scan(undefined)
102
- }
103
- // 8 sessions at a time.
104
- const results: boolean[] = new Array(recent.length)
105
- const next = { index: 0 }
106
- await Promise.all(Array.from({ length: Math.min(8, recent.length) }, async () => {
107
- while (next.index < recent.length) {
108
- const index = next.index++
109
- const result = await hit(recent[index]!)
110
- if (result instanceof Error) fail(result)
111
- results[index] = result
112
- }
113
- }))
114
- const found = recent.filter((_, index) => results[index])
115
- printRows({ json: options.json, rows: found, line: (session) => `${session.id} ${session.title}` })
77
+ const dataDir = dataDirOrDefault(options.dataDir)
78
+ const result = await syncTranscripts({ client: await readClient(), dataDir, days, rebuild: Boolean(options.rebuild) })
79
+ if (result instanceof Error) fail(result)
80
+ if (result.failed > 0) process.exitCode = 1
81
+ if (options.json) return printJson({ dir: transcriptsDir({ dataDir }), ...result })
82
+ process.stdout.write(`${transcriptsDir({ dataDir })}: ${result.written} written, ${result.skipped} skipped, ${result.failed} failed\n`)
116
83
  })
117
84
 
118
85
  cli.command('session wait <id>', 'Wait until idle or waiting for input, then print the session')
@@ -37,6 +37,10 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
37
37
  const threads = await cli(['thread', 'list', '--channel', twin.channelId, '--json'])
38
38
  const users = await cli(['user', 'list', '--guild', twin.discord.guildId, '--query', 'tommy', '--json'])
39
39
  await cli(['upload-to-discord', path.join(server.root, 'report.txt'), '--session', started.sessionId])
40
+ // Many files keep argument order, numbered, images and files mixed.
41
+ fs.mkdirSync(path.join(server.root, 'other'), { recursive: true })
42
+ for (const name of ['b.png', 'my shot.png', 'other/b.png']) fs.writeFileSync(path.join(server.root, name), 'png')
43
+ await cli(['upload-to-discord', ...['b.png', 'report.txt', 'my shot.png', 'other/b.png'].map((name) => path.join(server.root, name)), '--session', started.sessionId])
40
44
  // The agent learns that nothing was posted.
41
45
  const missing = await cli(['upload-to-discord', path.join(server.root, 'missing.png'), '--session', started.sessionId]).catch((error: { stderr: string }) => error)
42
46
  expect(missing.stderr.replace(server.root, '<root>')).toMatchInlineSnapshot(`
@@ -51,7 +55,20 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
51
55
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
52
56
  ok
53
57
  -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
54
- [attachment: report.txt]"
58
+ [file: attachment://report.txt]
59
+ [attachment: report.txt]
60
+ -# 1. \`b.png\`
61
+ [media: attachment://b.png]
62
+ -# 2. \`report.txt\`
63
+ [file: attachment://report.txt]
64
+ -# 3. \`my shot.png\`
65
+ [media: attachment://my_shot.png]
66
+ -# 4. \`b.png\`
67
+ [media: attachment://4-b.png]
68
+ [attachment: b.png]
69
+ [attachment: report.txt]
70
+ [attachment: my_shot.png]
71
+ [attachment: 4-b.png]"
55
72
  `)
56
73
  expect(threads.stdout).toContain(started.threadId)
57
74
  expect(users.stdout).toContain(TEST_USER_ID)
@@ -89,12 +89,11 @@ test('CLI help documents the supported P7 commands', async () => {
89
89
  --exclude <id> Exclude session (repeatable)
90
90
  --json Output as JSON
91
91
 
92
- session search <query> Search titles, then real message content
92
+ session sync Write session transcripts to <data-dir>/transcripts for rg; skips current ones
93
93
  --data-dir <path> Data directory (default: ~/.kimaki)
94
- --project <path> Project (default: current directory)
95
- -c, --channel <id> Project of this Discord channel
96
- --all All projects
97
- --days <n> Recent days (default: 14; 0 = all)
94
+ --days <n> Sessions updated in the last n days (default: 14)
95
+ --all All sessions, any age
96
+ --rebuild Write transcripts again, also current ones
98
97
  --json Output as JSON
99
98
 
100
99
  session wait <id> Wait until idle or waiting for input, then print the session
@@ -316,7 +315,7 @@ test('CLI help documents the supported P7 commands', async () => {
316
315
  -q, --query <text> Name filter
317
316
  --json Output as JSON
318
317
 
319
- upload-to-discord <...files> Attach local files to a session thread or the Kimaki voice channel chat
318
+ upload-to-discord <...files> Attach local files to a session thread or the Kimaki voice channel chat. Pass many files in one call: Discord shows them numbered in argument order
320
319
  --data-dir <path> Data directory (default: ~/.kimaki)
321
320
  -s, --session <id> Session ID, Discord thread ID or thread URL (default: OPENCODE_SESSION_ID)
322
321
  -c, --channel <id> Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)
@@ -376,7 +375,9 @@ test('CLI help documents the supported P7 commands', async () => {
376
375
  --machine-name <name> Name in this machine's category "Kimaki <name>" (default: hostname)
377
376
  --restart-onboarding Choose credentials again
378
377
  --worktrees Use a fresh Git worktree for new sessions unless the channel overrides it
378
+ --allow-all-users Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)
379
379
  --voice <name> Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it
380
+ --disable-skills <list> Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)
380
381
  --no-analytics Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)
381
382
  --if-not-running Start only if no bot runs; never stop the running one (used by autostart)
382
383
  -h, --help Display this message
@@ -28,13 +28,15 @@ function cli(args: string[]) {
28
28
  cwd: process.cwd(), env: { ...process.env, KIMAKI_LOCK_PORT: String(bot.lock.port), KIMAKI_OPENCODE_SERVICE_FILE: server.serviceFile, KIMAKI_DISCORD_REST_URL: twin.discord.restUrl },
29
29
  })
30
30
  }
31
- test('session read, list, search, wait, url and events read real session history', async () => {
31
+ test('session read, list, sync, wait, url and events read real session history', async () => {
32
32
  const first = await send(bot, { channelId: twin.channelId, prompt: 'Reader content marker' })
33
33
  if (first instanceof Error) throw first
34
34
  if (!first.sessionId) throw new Error('Expected an AI session')
35
35
  await waitForFooter({ discord: twin.discord, threadId: first.threadId })
36
36
  const list = await cli(['session', 'list', '--project', server.projectDirectory, '--json'])
37
- const search = await cli(['session', 'search', 'Reader content', '--project', server.projectDirectory, '--json'])
37
+ const sync = JSON.parse((await cli(['session', 'sync', '--days', '1', '--json'])).stdout)
38
+ const again = JSON.parse((await cli(['session', 'sync', '--days', '1', '--json'])).stdout)
39
+ const rebuilt = JSON.parse((await cli(['session', 'sync', '--days', '1', '--rebuild', '--json'])).stdout)
38
40
  const read = await cli(['session', 'read', first.threadId])
39
41
  const wait = await cli(['session', 'wait', first.sessionId, '--timeout', '10s'])
40
42
  const url = await cli(['session', 'url', first.sessionId])
@@ -47,7 +49,10 @@ test('session read, list, search, wait, url and events read real session history
47
49
  -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
48
50
  `)
49
51
  expect(list.stdout).toContain(first.sessionId)
50
- expect(search.stdout).toContain(first.sessionId)
52
+ expect(sync.written + sync.skipped).toBeGreaterThan(0)
53
+ expect([again.written, again.failed, rebuilt.skipped, rebuilt.failed]).toEqual([0, 0, 0, 0])
54
+ expect(fs.readFileSync(path.join(dataDir, 'transcripts', `${first.sessionId}.md`), 'utf8')).toContain('## user')
55
+ expect(fs.readFileSync(path.join(dataDir, 'transcripts', `${first.sessionId}.md`), 'utf8')).toContain('Reader content marker')
51
56
  expect(read.stdout).toContain('Reader content marker')
52
57
  expect(wait.stdout).toContain('Reader content marker')
53
58
  expect(url.stdout).toContain(first.threadId)
@@ -1,4 +1,4 @@
1
- // CLI session commands: editors, search by channel, command, named fork, resume by folder.
1
+ // CLI session commands: editors, command, named fork, resume by folder.
2
2
 
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
@@ -68,16 +68,14 @@ test('session diff prints critique output for the session folder', async () => {
68
68
  expect(diff.stdout).toContain('No changes to display')
69
69
  })
70
70
 
71
- test('session editors lists sessions that wrote a file, skips failed tool calls; search accepts a channel', async () => {
71
+ test('session editors lists sessions that wrote a file, skips failed tool calls', async () => {
72
72
  const writer = await newSession('editor-marker write')
73
73
  // An edit of a missing file fails and must not be recorded.
74
74
  await newSession('fail-marker edit')
75
75
  const written = await cli(['session', 'editors', 'edited.txt', '--json'])
76
76
  const failed = await cli(['session', 'editors', path.join(server.projectDirectory, 'missing.txt')]).catch((error: Error & { code: number }) => error)
77
- const searched = await cli(['session', 'search', 'editor-marker', '--channel', twin.channelId])
78
77
  expect(JSON.parse(written.stdout).map((row: { sessionId: string }) => row.sessionId)).toEqual([writer.sessionId])
79
78
  expect(failed instanceof Error && failed.code).toBe(1)
80
- expect(searched.stdout).toContain(writer.sessionId)
81
79
  })
82
80
 
83
81
  test('session command, named fork, and resume without a channel use the session folder', async () => {
@@ -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)