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.
- package/dist/bot.d.ts +4 -2
- package/dist/bot.js.map +1 -1
- package/dist/builtin-skills.d.ts +3 -0
- package/dist/builtin-skills.js +29 -0
- package/dist/builtin-skills.js.map +1 -1
- package/dist/builtin-skills.test.js +73 -1
- package/dist/builtin-skills.test.js.map +1 -1
- package/dist/cli/bot.d.ts +1 -1
- package/dist/cli/bot.js +15 -6
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +3 -2
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/commands/login-commands.d.ts +1 -1
- package/dist/commands/preference-commands.d.ts +1 -1
- package/dist/commands/worktree-commands.d.ts +1 -1
- package/dist/hello-thread.e2e.test.js +31 -0
- package/dist/hello-thread.e2e.test.js.map +1 -1
- package/dist/ingress.js +11 -6
- package/dist/ingress.js.map +1 -1
- package/dist/lock-routes.d.ts +14 -14
- package/dist/main.d.ts +2 -0
- package/dist/main.js +1 -0
- package/dist/main.js.map +1 -1
- package/dist/markdown/markdown.test.js +10 -0
- package/dist/markdown/markdown.test.js.map +1 -1
- package/dist/markdown/render-markdown.js +3 -1
- package/dist/markdown/render-markdown.js.map +1 -1
- package/dist/plugin/index.js +8 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/prompt.d.ts +6 -6
- package/dist/scheduler.d.ts +1 -1
- package/dist/session-events.js +9 -5
- package/dist/session-events.js.map +1 -1
- package/dist/sessions.d.ts +10 -10
- package/dist/sessions.js +1 -0
- package/dist/sessions.js.map +1 -1
- package/dist/system-prompt.d.ts +3 -1
- package/dist/system-prompt.js +52 -43
- package/dist/system-prompt.js.map +1 -1
- package/dist/tunnel.d.ts +21 -0
- package/dist/tunnel.js +296 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/tunnel.test.d.ts +1 -0
- package/dist/tunnel.test.js +114 -0
- package/dist/tunnel.test.js.map +1 -0
- package/package.json +3 -3
- package/skills/kimaki-tunnel/SKILL.md +16 -11
- package/src/bot.ts +3 -0
- package/src/builtin-skills.test.ts +74 -1
- package/src/builtin-skills.ts +33 -0
- package/src/cli/bot.ts +13 -7
- package/src/cli-lock.e2e.test.ts +3 -2
- package/src/hello-thread.e2e.test.ts +35 -0
- package/src/ingress.ts +9 -5
- package/src/main.ts +4 -0
- package/src/markdown/markdown.test.ts +11 -0
- package/src/markdown/render-markdown.ts +3 -1
- package/src/plugin/index.ts +9 -2
- package/src/session-events.ts +8 -4
- package/src/sessions.ts +1 -0
- package/src/system-prompt.ts +56 -42
- package/src/tunnel.test.ts +115 -0
- 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
|
+
})
|
package/src/builtin-skills.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
298
|
-
await runTunnel({ port, command: command.length > 0 ? command : undefined,
|
|
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')
|
package/src/cli-lock.e2e.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
3
|
-
// ownership (only channels mapped in this
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/session-events.ts
CHANGED
|
@@ -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 `##
|
|
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,
|
|
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
|
-
//
|
|
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 }),
|