kimaki 1.2.0 → 1.4.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 (63) hide show
  1. package/dist/bot.d.ts +4 -2
  2. package/dist/bot.js.map +1 -1
  3. package/dist/builtin-skills.d.ts +3 -0
  4. package/dist/builtin-skills.js +29 -0
  5. package/dist/builtin-skills.js.map +1 -1
  6. package/dist/builtin-skills.test.js +73 -1
  7. package/dist/builtin-skills.test.js.map +1 -1
  8. package/dist/cli/bot.d.ts +1 -1
  9. package/dist/cli/bot.js +15 -6
  10. package/dist/cli/bot.js.map +1 -1
  11. package/dist/cli-lock.e2e.test.js +3 -2
  12. package/dist/cli-lock.e2e.test.js.map +1 -1
  13. package/dist/commands/login-commands.d.ts +1 -1
  14. package/dist/commands/preference-commands.d.ts +1 -1
  15. package/dist/commands/worktree-commands.d.ts +1 -1
  16. package/dist/hello-thread.e2e.test.js +31 -0
  17. package/dist/hello-thread.e2e.test.js.map +1 -1
  18. package/dist/ingress.js +11 -6
  19. package/dist/ingress.js.map +1 -1
  20. package/dist/lock-routes.d.ts +14 -14
  21. package/dist/main.d.ts +2 -0
  22. package/dist/main.js +1 -0
  23. package/dist/main.js.map +1 -1
  24. package/dist/markdown/markdown.test.js +10 -0
  25. package/dist/markdown/markdown.test.js.map +1 -1
  26. package/dist/markdown/render-markdown.js +3 -1
  27. package/dist/markdown/render-markdown.js.map +1 -1
  28. package/dist/plugin/index.js +8 -2
  29. package/dist/plugin/index.js.map +1 -1
  30. package/dist/prompt.d.ts +6 -6
  31. package/dist/scheduler.d.ts +1 -1
  32. package/dist/session-events.js +9 -5
  33. package/dist/session-events.js.map +1 -1
  34. package/dist/sessions.d.ts +10 -10
  35. package/dist/sessions.js +1 -0
  36. package/dist/sessions.js.map +1 -1
  37. package/dist/system-prompt.d.ts +3 -1
  38. package/dist/system-prompt.js +52 -43
  39. package/dist/system-prompt.js.map +1 -1
  40. package/dist/tunnel.d.ts +21 -0
  41. package/dist/tunnel.js +296 -0
  42. package/dist/tunnel.js.map +1 -0
  43. package/dist/tunnel.test.d.ts +1 -0
  44. package/dist/tunnel.test.js +114 -0
  45. package/dist/tunnel.test.js.map +1 -0
  46. package/package.json +3 -3
  47. package/skills/kimaki-tunnel/SKILL.md +16 -11
  48. package/src/bot.ts +3 -0
  49. package/src/builtin-skills.test.ts +74 -1
  50. package/src/builtin-skills.ts +33 -0
  51. package/src/cli/bot.ts +13 -7
  52. package/src/cli-lock.e2e.test.ts +3 -2
  53. package/src/hello-thread.e2e.test.ts +35 -0
  54. package/src/ingress.ts +9 -5
  55. package/src/main.ts +4 -0
  56. package/src/markdown/markdown.test.ts +11 -0
  57. package/src/markdown/render-markdown.ts +3 -1
  58. package/src/plugin/index.ts +9 -2
  59. package/src/session-events.ts +8 -4
  60. package/src/sessions.ts +1 -0
  61. package/src/system-prompt.ts +56 -42
  62. package/src/tunnel.test.ts +115 -0
  63. package/src/tunnel.ts +308 -0
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from 'vitest'
2
2
 
3
- import { BUILTIN_SKILL_IDS, disabledSkillRules, parseDisabledSkills, readBuiltinSkills } from './builtin-skills.ts'
3
+ import { BUILTIN_SKILL_IDS, disabledSkillRules, parseDisabledPromptSections, parseDisabledSkills, PROMPT_SECTIONS, readBuiltinSkills } from './builtin-skills.ts'
4
+ import { baseInstructions } from './system-prompt.ts'
4
5
 
5
6
  test('every built-in skill file parses', async () => {
6
7
  const skills = await readBuiltinSkills()
@@ -27,3 +28,75 @@ test('--disable-skills accepts all, a list, and rejects unknown ids', () => {
27
28
  ]
28
29
  `)
29
30
  })
31
+
32
+ test('--disable-prompt removes exactly the headings of each section', () => {
33
+ expect(parseDisabledPromptSections(['critique-diffs,discord-callouts', 'search-result-urls'])).toEqual(['critique-diffs', 'discord-callouts', 'search-result-urls'])
34
+ expect(parseDisabledPromptSections(['critique'])).toBeInstanceOf(Error)
35
+ const input = { sessionId: 'ses_1', threadId: '2', channelId: '3', guildId: '4', userId: '5', dataDir: '/data', channelTopic: null, agents: [], parentSessionId: null }
36
+ const headings = (text: string) => text.split('\n').filter((line) => line.startsWith('## '))
37
+ const full = headings(baseInstructions(input))
38
+ const removed = Object.fromEntries(PROMPT_SECTIONS.map((section) => {
39
+ const kept = new Set(headings(baseInstructions({ ...input, disabled: [section] })))
40
+ return [section, full.filter((heading) => !kept.has(heading))]
41
+ }))
42
+ expect(removed).toMatchInlineSnapshot(`
43
+ {
44
+ "action-buttons": [
45
+ "## action buttons",
46
+ "## ending conversations with options",
47
+ ],
48
+ "ascii-diagrams": [
49
+ "## diagrams",
50
+ ],
51
+ "critique-diffs": [
52
+ "## showing diffs",
53
+ ],
54
+ "cross-project-commands": [
55
+ "## cross-project commands",
56
+ ],
57
+ "discord-callouts": [
58
+ "## Callouts in Kimaki Discord",
59
+ "## Tests not fully green",
60
+ ],
61
+ "discord-permissions": [
62
+ "## permissions",
63
+ ],
64
+ "file-upload-requests": [
65
+ "## requesting files from the user",
66
+ ],
67
+ "kimaki-debugging": [
68
+ "## debugging kimaki issues",
69
+ ],
70
+ "markdown-formatting": [
71
+ "## markdown formatting",
72
+ ],
73
+ "search-result-urls": [
74
+ "## URLs in search results",
75
+ ],
76
+ "session-abort": [
77
+ "## aborting a session",
78
+ ],
79
+ "session-sleep": [
80
+ "## sleeping the session",
81
+ ],
82
+ "waiting-sessions": [
83
+ "## waiting for a session to finish",
84
+ ],
85
+ }
86
+ `)
87
+ expect(headings(baseInstructions({ ...input, disabled: [...PROMPT_SECTIONS] }))).toMatchInlineSnapshot(`
88
+ [
89
+ "## Discord output",
90
+ "## shell tool",
91
+ "## commit messages",
92
+ "## uploading files to discord",
93
+ "## generating audio from text",
94
+ "## archiving the current thread",
95
+ "## updating the session title",
96
+ "## discord user mentions",
97
+ "## starting new sessions from CLI",
98
+ "## scheduled sends and routines",
99
+ "## reading other sessions",
100
+ ]
101
+ `)
102
+ })
@@ -53,6 +53,39 @@ export function parseDisabledSkills(value: string | undefined): ConfigError | Bu
53
53
  return items as BuiltinSkillId[]
54
54
  }
55
55
 
56
+ // Static parts of the session instructions (system-prompt.ts) that
57
+ // `kimaki --disable-prompt <section>` removes. Here, not in system-prompt.ts,
58
+ // so the CLI help can list them without loading zod.
59
+ export const PROMPT_SECTIONS = [
60
+ 'discord-permissions',
61
+ 'kimaki-debugging',
62
+ 'file-upload-requests',
63
+ 'action-buttons',
64
+ 'session-sleep',
65
+ 'session-abort',
66
+ 'cross-project-commands',
67
+ 'waiting-sessions',
68
+ 'critique-diffs',
69
+ 'markdown-formatting',
70
+ 'discord-callouts',
71
+ 'search-result-urls',
72
+ 'ascii-diagrams',
73
+ ] as const
74
+
75
+ export type PromptSection = (typeof PROMPT_SECTIONS)[number]
76
+
77
+ function isPromptSection(item: string): item is PromptSection {
78
+ return PROMPT_SECTIONS.some((section) => section === item)
79
+ }
80
+
81
+ // Repeatable flag; each value can also be a comma separated list.
82
+ export function parseDisabledPromptSections(values: readonly string[] | undefined): ConfigError | PromptSection[] {
83
+ const items = (values ?? []).flatMap((value) => value.split(',')).map((item) => item.trim()).filter(Boolean)
84
+ const unknown = items.filter((item) => !isPromptSection(item))
85
+ if (unknown.length > 0) return new ConfigError({ reason: `Unknown prompt section ${unknown.join(', ')}. Choose from: ${PROMPT_SECTIONS.join(', ')}` })
86
+ return items.filter(isPromptSection)
87
+ }
88
+
56
89
  // OpenCode hides a skill from the model and the skill tool when its permission is denied.
57
90
  export function disabledSkillRules(disabled: readonly string[]) {
58
91
  return disabled.map((id) => ({ action: 'skill', resource: id, effect: 'deny' }) as const)
package/src/cli/bot.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Bot lifecycle and setup: the root command (start + onboarding), provider
2
2
  // login, tools (tunnel, tts), and bot status, logs, credentials and keys.
3
- // main.ts, onboarding.ts, traforo, SQLite and voice are loaded only by the
3
+ // main.ts, onboarding.ts, tunnel.ts, SQLite and voice are loaded only by the
4
4
  // commands that run them (see the note in shared.ts).
5
5
 
6
6
  import { spawn, type ChildProcess } from 'node:child_process'
@@ -8,10 +8,10 @@ import fs from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import { setTimeout as sleep } from 'node:timers/promises'
10
10
  import dedent from 'string-dedent'
11
- import type { Goke } from 'goke'
11
+ import { wrapJsonSchema, type Goke } from 'goke'
12
12
 
13
13
  import { distCliEntry, writeAutostartScript } from '../autostart.ts'
14
- import { parseDisabledSkills } from '../builtin-skills.ts'
14
+ import { parseDisabledPromptSections, parseDisabledSkills, PROMPT_SECTIONS } from '../builtin-skills.ts'
15
15
  import { callBot, DEFAULT_LOCK_PORT, RESTART_EXIT_CODE, startLockServer } from '../lock-server.ts'
16
16
  import { parseDuration } from '../duration.ts'
17
17
  import { createLogger, setLogFile } from '../logger.ts'
@@ -91,6 +91,7 @@ export function registerStartCommand(cli: Goke) {
91
91
  .option('--allow-all-users', 'Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)')
92
92
  .option('--voice <name>', 'Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it')
93
93
  .option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
94
+ .option('--disable-prompt <section>', wrapJsonSchema<string[]>({ type: 'array', items: { type: 'string' }, description: `System prompt section to remove from new sessions (repeatable): ${PROMPT_SECTIONS.join(', ')}` }))
94
95
  .option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
95
96
  .option('--if-not-running', 'Start only if no bot runs; never stop the running one (used by autostart)')
96
97
  .action(async (options) => {
@@ -129,6 +130,8 @@ export function registerStartCommand(cli: Goke) {
129
130
  const urls = gatewayUrlsFromEnv()
130
131
  const disabledSkills = parseDisabledSkills(options.disableSkills)
131
132
  if (disabledSkills instanceof Error) fail(disabledSkills)
133
+ const disabledPrompt = parseDisabledPromptSections(options.disablePrompt)
134
+ if (disabledPrompt instanceof Error) fail(disabledPrompt)
132
135
  const machine = options.machineName ?? defaultMachineName()
133
136
  // Validated before the lock, so a typo never stops the running bot. null: reset to the default.
134
137
  const voice = (() => {
@@ -219,6 +222,7 @@ export function registerStartCommand(cli: Goke) {
219
222
  autoWorktrees: Boolean(options.worktrees),
220
223
  allowAllUsers: Boolean(options.allowAllUsers),
221
224
  disabledSkills,
225
+ disabledPrompt,
222
226
  })
223
227
  if (bot instanceof Error) failStartup(bot)
224
228
  // exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
@@ -248,6 +252,7 @@ export function registerStartCommand(cli: Goke) {
248
252
  ...(options.worktrees ? ['--worktrees'] : []),
249
253
  ...(options.allowAllUsers ? ['--allow-all-users'] : []),
250
254
  ...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
255
+ ...disabledPrompt.flatMap((section) => ['--disable-prompt', section]),
251
256
  ...(options.noAnalytics ? ['--no-analytics'] : []),
252
257
  ...(options.machineName ? ['--machine-name', options.machineName] : []),
253
258
  ] })
@@ -283,9 +288,9 @@ export function registerLoginCommands(cli: Goke) {
283
288
 
284
289
  // tunnel, tts
285
290
  export function registerToolCommands(cli: Goke) {
286
- cli.command('tunnel', 'Run a command and expose its local port with a public URL. The child gets TRAFORO_URL')
291
+ cli.command('tunnel', 'Run a command and expose its local port with a public opentunnel.xyz URL. The child gets KIMAKI_TUNNEL_URL')
287
292
  .option('-p, --port <port>', 'Local port (default: read from the command output)')
288
- .option('-t, --tunnel-id <id>', 'Fixed tunnel ID (default: random). Only for public-safe services')
293
+ .option('-t, --tunnel-id <id>', 'Fixed subdomain (default: random). Only for public-safe services')
289
294
  .option('--host <host>', 'Local host (default: localhost)')
290
295
  .option('-k, --kill', 'Kill the process on --port first')
291
296
  .example('kimaki tunnel -- pnpm dev')
@@ -294,8 +299,9 @@ export function registerToolCommands(cli: Goke) {
294
299
  const port = options.port ? Number(options.port) : undefined
295
300
  if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) fail(new Error(`Invalid --port ${options.port}`))
296
301
  if (!port && command.length === 0) fail(new Error('Pass a command after --, or --port <port>. Example: kimaki tunnel -- pnpm dev'))
297
- const { runTunnel } = await import('traforo/run-tunnel')
298
- await runTunnel({ port, command: command.length > 0 ? command : undefined, tunnelId: options.tunnelId, localHost: options.host, baseDomain: 'kimaki.dev', kill: options.kill })
302
+ const { runTunnel } = await import('../tunnel.ts')
303
+ const result = await runTunnel({ port, command: command.length > 0 ? command : undefined, name: options.tunnelId, host: options.host, kill: options.kill })
304
+ if (result instanceof Error) fail(result)
299
305
  })
300
306
 
301
307
  cli.command('tts [text]', 'Text to speech with OpenAI or Gemini. Reads stdin if no text is given')
@@ -321,9 +321,9 @@ test('CLI help documents the supported P7 commands', async () => {
321
321
  -c, --channel <id> Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)
322
322
 
323
323
  Tools:
324
- tunnel Run a command and expose its local port with a public URL. The child gets TRAFORO_URL
324
+ tunnel Run a command and expose its local port with a public opentunnel.xyz URL. The child gets KIMAKI_TUNNEL_URL
325
325
  -p, --port <port> Local port (default: read from the command output)
326
- -t, --tunnel-id <id> Fixed tunnel ID (default: random). Only for public-safe services
326
+ -t, --tunnel-id <id> Fixed subdomain (default: random). Only for public-safe services
327
327
  --host <host> Local host (default: localhost)
328
328
  -k, --kill Kill the process on --port first
329
329
 
@@ -378,6 +378,7 @@ test('CLI help documents the supported P7 commands', async () => {
378
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
380
  --disable-skills <list> Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)
381
+ --disable-prompt <section> System prompt section to remove from new sessions (repeatable): discord-permissions, kimaki-debugging, file-upload-requests, action-buttons, session-sleep, session-abort, cross-project-commands, waiting-sessions, critique-diffs, markdown-formatting, discord-callouts, search-result-urls, ascii-diagrams
381
382
  --no-analytics Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)
382
383
  --if-not-running Start only if no bot runs; never stop the running one (used by autostart)
383
384
  -h, --help Display this message
@@ -177,6 +177,41 @@ test('message from a user without permission creates no thread', async () => {
177
177
  await expectNoNewThread({ channelId, before })
178
178
  })
179
179
 
180
+ test('another bot needs the Kimaki role, and no-kimaki still denies it', async () => {
181
+ const { discord, channelId } = twin
182
+ const helperBotId = '200000000000000050'
183
+ const guildId = discord.guildId
184
+ await discord.prisma.user.create({ data: { id: helperBotId, username: 'helper-bot', bot: true } })
185
+ await discord.prisma.guildMember.create({ data: { guildId, userId: helperBotId } })
186
+ const member = { guildId_userId: { guildId, userId: helperBotId } }
187
+ const helper = discord.channel(channelId).user(helperBotId)
188
+
189
+ const before = (await discord.channel(channelId).getThreads()).length
190
+ await helper.sendMessage({ content: 'no role bot-marker hello-marker' })
191
+ await expectNoNewThread({ channelId, before })
192
+
193
+ const guild = await bot.discord.guilds.fetch(guildId)
194
+ const kimakiRole = await guild.roles.create({ name: 'Kimaki' })
195
+ const denyRole = await guild.roles.create({ name: 'no-kimaki' })
196
+ await discord.prisma.guildMember.update({ where: member, data: { roles: JSON.stringify([kimakiRole.id]) } })
197
+ await helper.sendMessage({ content: 'with role bot-marker hello-marker' })
198
+ const thread = await discord.channel(channelId).waitForThread({ predicate: (thread) => thread.name?.startsWith('with role bot-marker') ?? false })
199
+ await waitForFooter({ discord, threadId: thread.id })
200
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
201
+ "--- from: assistant (helper-bot)
202
+ with role bot-marker hello-marker
203
+ --- from: assistant (TestBot)
204
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
205
+ Hello from the deterministic model.
206
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
207
+ `)
208
+
209
+ const withRole = (await discord.channel(channelId).getThreads()).length
210
+ await discord.prisma.guildMember.update({ where: member, data: { roles: JSON.stringify([kimakiRole.id, denyRole.id]) } })
211
+ await helper.sendMessage({ content: 'denied bot-marker hello-marker' })
212
+ await expectNoNewThread({ channelId, before: withRole })
213
+ })
214
+
180
215
  test('message in a channel without a project creates no thread', async () => {
181
216
  const { discord, unregisteredChannelId } = twin
182
217
  await discord.channel(unregisteredChannelId).user(TEST_USER_ID).sendMessage({ content: 'hello-marker' })
package/src/ingress.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Discord ingress (spec 4): gates every messageCreate, parses it into a Route
2
- // and calls actions. Gates, in order: bots (including ourselves), channel
3
- // ownership (only channels mapped in this machine's SQLite), permission.
2
+ // and calls actions. Gates, in order: webhooks and our own messages (except
3
+ // remote send envelopes), channel ownership (only channels mapped in this
4
+ // machine's SQLite), permission (other bots need the Kimaki role).
4
5
  // Messages of one channel are handled in arrival order, after OpenCode is ready.
5
6
  //
6
7
  // Messages sent while the bot was offline are not fetched here: gateway-proxy
@@ -66,6 +67,8 @@ function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean>
66
67
  function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
67
68
  const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
68
69
  if (roleNames.includes('no-kimaki')) return false
70
+ // Other bots are opt-in: only the Kimaki role lets them in, never admin rights or --allow-all-users.
71
+ if (member.user.bot) return roleNames.includes('kimaki')
69
72
  if (bot.allowAllUsers) return true
70
73
  if (member.guild.ownerId === member.id) return true
71
74
  if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
@@ -222,8 +225,7 @@ async function handleMessage(bot: Bot, message: Message) {
222
225
  })
223
226
  return
224
227
  }
225
- if (message.author.bot) {
226
- if (message.author.id !== bot.discord.user?.id) return
228
+ if (message.author.id === bot.discord.user?.id) {
227
229
  return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
228
230
  }
229
231
  if (!(await authorCanUseKimaki(bot, message))) {
@@ -303,7 +305,9 @@ export function registerIngress(bot: Bot) {
303
305
  }
304
306
 
305
307
  bot.discord.on(Events.MessageCreate, (message) => {
306
- if (message.author.bot && !message.embeds[0]?.footer?.text.startsWith(REMOTE_SEND_PREFIX)) return
308
+ // Webhooks have no member to check. Own messages only matter as remote send envelopes.
309
+ if (message.webhookId) return
310
+ if (message.author.id === bot.discord.user?.id && !message.embeds[0]?.footer?.text.startsWith(REMOTE_SEND_PREFIX)) return
307
311
  serialize(message.channelId, () => handleMessage(bot, message))
308
312
  })
309
313
  // Deleting a queued Discord message removes it from the queue.
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import * as errore from 'errore'
9
9
  import { createAgentUi } from './agent-ui.ts'
10
10
  import type { Analytics } from './analytics.ts'
11
11
  import { oc, type Bot } from './bot.ts'
12
+ import type { PromptSection } from './builtin-skills.ts'
12
13
  import { openDb } from './db.ts'
13
14
  import { createEffectsRunner } from './effects.ts'
14
15
  import { DiscordError } from './errors.ts'
@@ -68,6 +69,8 @@ export type StartBotOptions = {
68
69
  allowAllUsers?: boolean
69
70
  // Built-in skill ids denied in new sessions (builtin-skills.ts).
70
71
  disabledSkills?: readonly string[]
72
+ // Static instruction parts left out of new sessions (system-prompt.ts).
73
+ disabledPrompt?: readonly PromptSection[]
71
74
  }
72
75
 
73
76
  export type BotHandle = Bot & {
@@ -185,6 +188,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
185
188
  autoWorktrees: options.autoWorktrees ?? false,
186
189
  allowAllUsers: options.allowAllUsers ?? false,
187
190
  disabledSkills: options.disabledSkills ?? [],
191
+ disabledPrompt: options.disabledPrompt ?? [],
188
192
  features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
189
193
  }
190
194
  const scheduler = createScheduler({
@@ -412,3 +412,14 @@ test('edge cases: 2500-char code line, fence info line inside a fence, oversized
412
412
 
413
413
  expect(markdownPayloads('<callout accent="#ef4444"></callout>')).toMatchInlineSnapshot(`[]`)
414
414
  })
415
+
416
+ test('empty blockquote lines get a trailing space so Discord keeps the quote', () => {
417
+ const text = '**1. To a@b.com**\n\n> Hi Mohammad,\n>\n> Your subscription is cancelled.\n>\n> Tommy\n\nDone.'
418
+ const segments = renderMarkdown(text)
419
+ expect(segments.map((segment) => ('markdown' in segment ? JSON.stringify(segment.markdown.split('\n')) : segment.kind)))
420
+ .toMatchInlineSnapshot(`
421
+ [
422
+ "["**1. To a@b.com**","","> Hi Mohammad,","> ","> Your subscription is cancelled.","> ","> Tommy","","Done."]",
423
+ ]
424
+ `)
425
+ })
@@ -5,6 +5,7 @@
5
5
  // ├─ table ──▶ table segment (Components V2 later)
6
6
  // ├─ heading depth > 3 ──▶ clamped to ###
7
7
  // ├─ list with code items ──▶ code lifted after its item
8
+ // ├─ blockquote ──▶ bare ">" lines become "> "
8
9
  // └─ other nodes ──▶ original source slices, packed into ≤ limit
9
10
  //
10
11
  // Text is sliced from the original source by node offsets, never
@@ -217,7 +218,8 @@ function nodePieces(source: string, node: RootContent, limit: number): string[]
217
218
  return unnestList(source, node, limit).flatMap((piece) => (piece.length <= limit ? [piece] : splitText(piece, limit)))
218
219
  }
219
220
  if (node.type === 'code') return codePieces(source, node, limit)
220
- const text = slice(source, node)
221
+ // Discord quotes need "> " plus a space; a bare ">" line shows as a literal ">".
222
+ const text = node.type === 'blockquote' ? slice(source, node).replace(/^( *>)$/gm, '$1 ') : slice(source, node)
221
223
  if (text.length <= limit) return [text]
222
224
  if (node.type === 'list') {
223
225
  const items = node.children.map((item) => slice(source, item))
@@ -1,4 +1,5 @@
1
1
  import { Plugin, Skill } from '@opencode/plugin'
2
+ import type { SessionContext } from '@opencode/plugin/promise/session'
2
3
  import { Schema } from 'effect'
3
4
  import { execFile } from 'node:child_process'
4
5
  import { promisify } from 'node:util'
@@ -58,7 +59,10 @@ export default Plugin.define({
58
59
  if (last?.type === 'synthetic' && last.text === text) return
59
60
  await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
60
61
  })
61
- await ctx.session.hook('context', async (event) => {
62
+ // Runs for every request kind: turns (context), compaction and generate (OpenCode
63
+ // `warming` keep-alive requests). All kinds must get the same system text, tools
64
+ // and messages, or the provider prompt cache misses.
65
+ const shapeRequest = async (event: SessionContext) => {
62
66
  // The only prompt change for every session, TUI included. The ID never
63
67
  // changes in a session, so the cached prompt prefix stays stable.
64
68
  event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
@@ -88,7 +92,10 @@ export default Plugin.define({
88
92
  ...properties,
89
93
  } }
90
94
  }
91
- })
95
+ }
96
+ await ctx.session.hook('context', shapeRequest)
97
+ await ctx.session.hook('compaction', shapeRequest)
98
+ await ctx.session.hook('generate', shapeRequest)
92
99
  await ctx.tool.hook('execute.before', async (event) => {
93
100
  if (event.tool !== 'shell' || !event.input || typeof event.input !== 'object' || Array.isArray(event.input)) return
94
101
  const info = await marker(event.sessionID)
@@ -110,9 +110,13 @@ export async function resolveSession({
110
110
  type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
111
111
  type Session = Awaited<ReturnType<OpenCodeClient['session']['get']>>
112
112
 
113
+ // Long enough for most edits, commands and subagent prompts; huge writes stay short.
114
+ const TOOL_INPUT_LIMIT = 5000
115
+
113
116
  function renderMessage(message: Message, options: { thinking?: boolean; verbose?: boolean }): string {
114
117
  const time = new Date(message.time.created).toISOString()
115
- if (message.type === 'user') return `## user ${time}\n\n${message.text}`
118
+ if (message.type === 'user' || message.type === 'synthetic' || message.type === 'system') return `## ${message.type} ${time}\n\n${message.text}`
119
+ if (message.type === 'compaction' && message.status !== 'failed') return `## compaction ${time}\n\n${message.summary}`
116
120
  if (message.type !== 'assistant') return `## ${message.type} ${time}\n\n${JSON.stringify(message)}`
117
121
  const header = `## assistant ${time} (${message.agent}, ${message.model.providerID}/${message.model.id})`
118
122
  const parts = message.content.map((part) => {
@@ -120,7 +124,7 @@ function renderMessage(message: Message, options: { thinking?: boolean; verbose?
120
124
  if (part.type === 'reasoning') return options.thinking ? part.text : ''
121
125
  if (part.type === 'tool') {
122
126
  const state = part.state
123
- const input = options.verbose ? JSON.stringify(state.input ?? {}) : JSON.stringify(state.input ?? {}).slice(0, 500)
127
+ const input = options.verbose ? JSON.stringify(state.input ?? {}) : JSON.stringify(state.input ?? {}).slice(0, TOOL_INPUT_LIMIT)
124
128
  const error = state.status === 'error' ? `\nerror: ${JSON.stringify(state).slice(0, 500)}` : ''
125
129
  const output = options.verbose && state.status === 'completed' ? `\n${JSON.stringify(state.content)}` : ''
126
130
  return `tool ${part.name} [${state.status}] ${input}${error}${output}`
@@ -158,7 +162,7 @@ function renderSession({ session, messages, thinking, verbose }: { session: Pick
158
162
  }
159
163
 
160
164
  // Transcripts: <dataDir>/transcripts/<sessionId>.md, the `session read` markdown
161
- // (no thinking, no tool outputs) so agents can search every session with rg.
165
+ // with thinking but no tool outputs, so agents can search every session with rg.
162
166
  // The file mtime is the session's time.updated: a file at least that new is current.
163
167
  export function transcriptsDir({ dataDir }: { dataDir: string }): string {
164
168
  return path.join(dataDir, 'transcripts')
@@ -172,7 +176,7 @@ export async function writeTranscript({ client, dataDir, session }: { client: Op
172
176
  const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
173
177
  const written = await (async () => {
174
178
  await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 })
175
- await fs.promises.writeFile(temp, `${renderSession({ session, messages })}\n`, { mode: 0o600 })
179
+ await fs.promises.writeFile(temp, `${renderSession({ session, messages, thinking: true })}\n`, { mode: 0o600 })
176
180
  await fs.promises.utimes(temp, session.time.updated / 1000, session.time.updated / 1000)
177
181
  await fs.promises.rename(temp, file)
178
182
  })().catch((cause) => new ConfigError({ reason: `Cannot write ${file}`, cause }))
package/src/sessions.ts CHANGED
@@ -203,6 +203,7 @@ async function putInstructions(
203
203
  agents,
204
204
  parentSessionId,
205
205
  scheduledTask,
206
+ disabled: bot.disabledPrompt,
206
207
  })
207
208
  const put = await oc(bot, 'instructions.entry.put', (client) =>
208
209
  client.session.instructions.entry.put({ sessionID: sessionId, key: INSTRUCTION_KEY, value }),