kimaki 1.1.0 → 1.3.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 +6 -1
- package/dist/bot.js +9 -3
- package/dist/bot.js.map +1 -1
- package/dist/cli/bot.js +13 -5
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +4 -2
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/cli-sessions.e2e.test.js +2 -4
- package/dist/cli-sessions.e2e.test.js.map +1 -1
- package/dist/commands/worktree-commands.d.ts +1 -1
- package/dist/event-loop.d.ts +2 -1
- package/dist/event-loop.js +2 -2
- package/dist/event-loop.js.map +1 -1
- package/dist/hello-thread.e2e.test.js +31 -0
- package/dist/hello-thread.e2e.test.js.map +1 -1
- package/dist/ingress.d.ts +1 -1
- package/dist/ingress.js +25 -17
- package/dist/ingress.js.map +1 -1
- package/dist/lock-routes.d.ts +13 -13
- package/dist/main.d.ts +2 -0
- package/dist/main.js +3 -1
- package/dist/main.js.map +1 -1
- package/dist/plugin/index.js +42 -9
- package/dist/plugin/index.js.map +1 -1
- package/dist/scheduler.d.ts +1 -1
- package/dist/session-events.d.ts +1 -1
- package/dist/sessions.d.ts +5 -5
- package/dist/sessions.js +3 -3
- package/dist/sessions.js.map +1 -1
- package/dist/slash-commands.js +1 -1
- package/dist/slash-commands.js.map +1 -1
- package/dist/system-prompt.d.ts +14 -0
- package/dist/system-prompt.js +34 -0
- package/dist/system-prompt.js.map +1 -1
- package/dist/test/harness.d.ts +2 -1
- package/dist/test/harness.js +2 -1
- package/dist/test/harness.js.map +1 -1
- package/dist/test/replay.js +1 -0
- package/dist/test/replay.js.map +1 -1
- package/dist/thread-reducer.d.ts +3 -0
- package/dist/thread-reducer.fixtures.test.js +2 -2
- package/dist/thread-reducer.fixtures.test.js.map +1 -1
- package/dist/thread-reducer.js +32 -4
- package/dist/thread-reducer.js.map +1 -1
- package/dist/thread-reducer.test.js +31 -0
- package/dist/thread-reducer.test.js.map +1 -1
- package/dist/tunnel.d.ts +12 -0
- package/dist/tunnel.js +207 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/tunnel.test.d.ts +1 -0
- package/dist/tunnel.test.js +38 -0
- package/dist/tunnel.test.js.map +1 -0
- package/dist/voice-call.js +1 -1
- package/dist/voice-call.js.map +1 -1
- package/package.json +5 -5
- package/skills/kimaki-tunnel/SKILL.md +14 -10
- package/src/bot.ts +16 -4
- package/src/cli/bot.ts +12 -5
- package/src/cli-lock.e2e.test.ts +4 -2
- package/src/event-loop.ts +4 -1
- package/src/hello-thread.e2e.test.ts +35 -0
- package/src/ingress.ts +25 -16
- package/src/main.ts +6 -1
- package/src/message-tool.e2e.test.ts +45 -0
- package/src/plugin/index.ts +42 -9
- package/src/sessions.ts +3 -2
- package/src/slash-commands.ts +1 -1
- package/src/system-prompt.ts +38 -0
- package/src/test/harness.ts +3 -0
- package/src/test/replay.ts +1 -0
- package/src/thread-reducer.fixtures.test.ts +2 -2
- package/src/thread-reducer.test.ts +31 -0
- package/src/thread-reducer.ts +36 -4
- package/src/tunnel.test.ts +39 -0
- package/src/tunnel.ts +209 -0
- package/src/voice-call.ts +1 -1
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
|
|
@@ -39,9 +40,13 @@ import { isVoiceAttachment, parseVoiceMessage, transcribe, voiceMediaType, type
|
|
|
39
40
|
|
|
40
41
|
const logger = createLogger('INGRESS')
|
|
41
42
|
|
|
42
|
-
// Owner, Administrator, Manage Server, or a role named "Kimaki"
|
|
43
|
-
// "no-kimaki" always denies.
|
|
44
|
-
|
|
43
|
+
// Owner, Administrator, Manage Server, or a role named "Kimaki"; with
|
|
44
|
+
// --allow-all-users every member. A role named "no-kimaki" always denies.
|
|
45
|
+
// Missing member data fails closed.
|
|
46
|
+
export async function canUseKimaki(
|
|
47
|
+
bot: Pick<Bot, 'allowAllUsers'>,
|
|
48
|
+
{ guild, userId }: { guild: Guild; userId: string },
|
|
49
|
+
): Promise<boolean> {
|
|
45
50
|
const member = await guild.members.fetch({ user: userId, force: true }).catch((error: Error) => error)
|
|
46
51
|
if (member instanceof Error) {
|
|
47
52
|
// Still denied, but say why: a REST failure must not look like a missing role.
|
|
@@ -49,19 +54,22 @@ export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: st
|
|
|
49
54
|
return false
|
|
50
55
|
}
|
|
51
56
|
if (!(member instanceof GuildMember)) return false
|
|
52
|
-
return memberCanUseKimaki(member)
|
|
57
|
+
return memberCanUseKimaki(bot, member)
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
// MESSAGE_CREATE carries the author's current roles: no REST fetch on the hot path.
|
|
56
61
|
// Discord omits the member only for webhook and ephemeral messages, which never get here.
|
|
57
|
-
function authorCanUseKimaki(message: Message<true>): Promise<boolean> {
|
|
58
|
-
if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(message.member))
|
|
59
|
-
return canUseKimaki({ guild: message.guild, userId: message.author.id })
|
|
62
|
+
function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean> {
|
|
63
|
+
if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(bot, message.member))
|
|
64
|
+
return canUseKimaki(bot, { guild: message.guild, userId: message.author.id })
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
function memberCanUseKimaki(member: GuildMember): boolean {
|
|
67
|
+
function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
|
|
63
68
|
const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
|
|
64
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')
|
|
72
|
+
if (bot.allowAllUsers) return true
|
|
65
73
|
if (member.guild.ownerId === member.id) return true
|
|
66
74
|
if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
|
|
67
75
|
if (member.permissions.has(PermissionFlagsBits.ManageGuild)) return true
|
|
@@ -208,7 +216,7 @@ async function handleMessage(bot: Bot, message: Message) {
|
|
|
208
216
|
if (!project) return
|
|
209
217
|
if (project.channel_type === 'voice') {
|
|
210
218
|
// The text chat of the voice channel talks to the running call.
|
|
211
|
-
if (message.author.bot || thread || !(await authorCanUseKimaki(message))) return
|
|
219
|
+
if (message.author.bot || thread || !(await authorCanUseKimaki(bot, message))) return
|
|
212
220
|
bot.features.voiceCalls.text(bot, {
|
|
213
221
|
guildId: message.guild.id,
|
|
214
222
|
channelId,
|
|
@@ -217,11 +225,10 @@ async function handleMessage(bot: Bot, message: Message) {
|
|
|
217
225
|
})
|
|
218
226
|
return
|
|
219
227
|
}
|
|
220
|
-
if (message.author.bot) {
|
|
221
|
-
if (message.author.id !== bot.discord.user?.id) return
|
|
228
|
+
if (message.author.id === bot.discord.user?.id) {
|
|
222
229
|
return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
|
|
223
230
|
}
|
|
224
|
-
if (!(await authorCanUseKimaki(message))) {
|
|
231
|
+
if (!(await authorCanUseKimaki(bot, message))) {
|
|
225
232
|
logger.log(`ignoring ${message.author.username}: no Kimaki permission`)
|
|
226
233
|
return
|
|
227
234
|
}
|
|
@@ -298,7 +305,9 @@ export function registerIngress(bot: Bot) {
|
|
|
298
305
|
}
|
|
299
306
|
|
|
300
307
|
bot.discord.on(Events.MessageCreate, (message) => {
|
|
301
|
-
|
|
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
|
|
302
311
|
serialize(message.channelId, () => handleMessage(bot, message))
|
|
303
312
|
})
|
|
304
313
|
// Deleting a queued Discord message removes it from the queue.
|
|
@@ -317,7 +326,7 @@ export function registerIngress(bot: Bot) {
|
|
|
317
326
|
serialize(message.channelId, async () => {
|
|
318
327
|
const full = message.partial ? await message.fetch().catch(() => null) : message
|
|
319
328
|
if (!full?.guild || !queuedItemFor(bot, { threadId: full.channelId, messageId: full.id })) return
|
|
320
|
-
if (!(await canUseKimaki({ guild: full.guild, userId: full.author.id }))) return
|
|
329
|
+
if (!(await canUseKimaki(bot, { guild: full.guild, userId: full.author.id }))) return
|
|
321
330
|
// The re-queued prompt must carry the message's attachments again.
|
|
322
331
|
const files = await saveAttachments({ dataDir: bot.dataDir, messageId: full.id, attachments: [...full.attachments.values()] })
|
|
323
332
|
if (files instanceof Error) {
|
package/src/main.ts
CHANGED
|
@@ -65,8 +65,11 @@ export type StartBotOptions = {
|
|
|
65
65
|
// How often due tasks and wakes run; null: never (tests call scheduler.runDueTasks).
|
|
66
66
|
schedulerIntervalMs?: number | null
|
|
67
67
|
autoWorktrees?: boolean
|
|
68
|
+
allowAllUsers?: boolean
|
|
68
69
|
// Built-in skill ids denied in new sessions (builtin-skills.ts).
|
|
69
70
|
disabledSkills?: readonly string[]
|
|
71
|
+
// `kimaki --message-tool`: agents also reply through send_message; Discord hides their plain text.
|
|
72
|
+
messageTool?: boolean
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export type BotHandle = Bot & {
|
|
@@ -145,7 +148,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
145
148
|
if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
|
|
146
149
|
})
|
|
147
150
|
}
|
|
148
|
-
const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
|
|
151
|
+
const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, messageTool: options.messageTool ?? false, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
|
|
149
152
|
const loaded = await eventLoop.load()
|
|
150
153
|
if (loaded instanceof Error) return loaded
|
|
151
154
|
// Before the service is used, so a service started by ensure() loads it at once.
|
|
@@ -182,7 +185,9 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
182
185
|
transcriptionBaseUrls: options.transcriptionBaseUrls ?? {},
|
|
183
186
|
realtimeBaseUrls: options.realtimeBaseUrls ?? {},
|
|
184
187
|
autoWorktrees: options.autoWorktrees ?? false,
|
|
188
|
+
allowAllUsers: options.allowAllUsers ?? false,
|
|
185
189
|
disabledSkills: options.disabledSkills ?? [],
|
|
190
|
+
messageTool: options.messageTool ?? false,
|
|
186
191
|
features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
|
|
187
192
|
}
|
|
188
193
|
const scheduler = createScheduler({
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `kimaki --message-tool`: the plugin gives root sessions a send_message tool
|
|
2
|
+
// and the thread shows only its text. The model's plain text stays hidden.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import type { BotHandle } from './main.ts'
|
|
8
|
+
import { scriptedTurn, seedProjectChannel, startOpencodeTestServer, startTestBot, startTwin, tempDataDir, TEST_USER_ID, waitForFooter, warmUp, type OpencodeTestServer, type TestTwin } from './test/harness.ts'
|
|
9
|
+
|
|
10
|
+
const dataDir = tempDataDir()
|
|
11
|
+
let server: OpencodeTestServer
|
|
12
|
+
let twin: TestTwin
|
|
13
|
+
let bot: BotHandle
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
const matchers = scriptedTurn({
|
|
17
|
+
marker: 'message-tool-marker',
|
|
18
|
+
steps: [{ id: 'message-call', tool: 'send_message', input: { text: 'Hello from **send_message**' } }],
|
|
19
|
+
finalText: 'hidden scratchpad text',
|
|
20
|
+
})
|
|
21
|
+
;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers }), startTwin()])
|
|
22
|
+
await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
|
|
23
|
+
bot = await startTestBot({ dataDir, twin, server, messageTool: true })
|
|
24
|
+
await warmUp({ server })
|
|
25
|
+
}, 60_000)
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await bot?.stop()
|
|
29
|
+
await Promise.all([server?.stop(), twin?.stop()])
|
|
30
|
+
fs.rmSync(dataDir, { recursive: true, force: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('send_message text shows in Discord, plain text of the same turn does not', async () => {
|
|
34
|
+
await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'message-tool-marker' })
|
|
35
|
+
const thread = await twin.discord.channel(twin.channelId).waitForThread({ timeout: 8_000 })
|
|
36
|
+
await waitForFooter({ discord: twin.discord, threadId: thread.id })
|
|
37
|
+
expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
38
|
+
"--- from: user (tommy)
|
|
39
|
+
message-tool-marker
|
|
40
|
+
--- from: assistant (TestBot)
|
|
41
|
+
-# *using deterministic-provider/deterministic-v2 ⋅ build*
|
|
42
|
+
Hello from **send_message**
|
|
43
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
44
|
+
`)
|
|
45
|
+
})
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from '@opencode/client'
|
|
1
2
|
import { Plugin, Skill } from '@opencode/plugin'
|
|
2
3
|
import { Schema } from 'effect'
|
|
3
4
|
import { execFile } from 'node:child_process'
|
|
@@ -7,7 +8,7 @@ import path from 'node:path'
|
|
|
7
8
|
import { autostartBot } from '../autostart.ts'
|
|
8
9
|
import { readBuiltinSkills } from '../builtin-skills.ts'
|
|
9
10
|
import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
|
|
10
|
-
import { turnContext } from '../system-prompt.ts'
|
|
11
|
+
import { MESSAGE_MODE_INSTRUCTIONS, MESSAGE_TOOL, MESSAGE_TOOL_DESCRIPTION, MESSAGE_TOOL_INPUT, turnContext } from '../system-prompt.ts'
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execFile)
|
|
13
14
|
|
|
@@ -31,17 +32,32 @@ export default Plugin.define({
|
|
|
31
32
|
}
|
|
32
33
|
})
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
// metadata.kimaki of the session, or of the thread's root for a subagent (`root: false`).
|
|
36
|
+
async function marker(sessionID: string): Promise<{ fields: Map<string, JsonValue>; root: boolean } | null> {
|
|
35
37
|
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
36
38
|
const value: unknown = session?.metadata?.['kimaki']
|
|
37
39
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
38
40
|
const current = new Map(Object.entries(value))
|
|
39
|
-
if (!session?.parentID) return current
|
|
41
|
+
if (!session?.parentID) return { fields: current, root: true }
|
|
40
42
|
const parent = await ctx.session.get({ sessionID: session.parentID }).catch(() => null)
|
|
41
43
|
const parentMarker = parent?.metadata?.['kimaki']
|
|
42
|
-
if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId'))
|
|
43
|
-
|
|
44
|
+
if (parentMarker && typeof parentMarker === 'object' && !Array.isArray(parentMarker) && parentMarker['threadId'] === current.get('threadId')) {
|
|
45
|
+
const root = await marker(session.parentID)
|
|
46
|
+
return root && { fields: root.fields, root: false }
|
|
47
|
+
}
|
|
48
|
+
return { fields: current, root: true }
|
|
44
49
|
}
|
|
50
|
+
// Registered for every session; the context hook removes it outside message mode.
|
|
51
|
+
await ctx.tool.transform((editor) => {
|
|
52
|
+
editor.add({
|
|
53
|
+
name: MESSAGE_TOOL,
|
|
54
|
+
description: MESSAGE_TOOL_DESCRIPTION,
|
|
55
|
+
input: MESSAGE_TOOL_INPUT,
|
|
56
|
+
options: { codemode: false },
|
|
57
|
+
// Discord renders the call input (thread-reducer.ts); the tool itself does nothing.
|
|
58
|
+
execute: async () => ({ content: 'Message sent to the user.' }),
|
|
59
|
+
})
|
|
60
|
+
})
|
|
45
61
|
// The git branch reaches the model as a synthetic message saved right before
|
|
46
62
|
// the user prompt, only when it changed. Text in event.system would change the
|
|
47
63
|
// cached prompt prefix on every branch switch. The prompt hook runs before
|
|
@@ -58,11 +74,28 @@ export default Plugin.define({
|
|
|
58
74
|
if (last?.type === 'synthetic' && last.text === text) return
|
|
59
75
|
await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
|
|
60
76
|
})
|
|
77
|
+
// send_message only for root sessions of a bot started with --message-tool.
|
|
78
|
+
// Every request kind that carries the tool list gets the same tools and system
|
|
79
|
+
// text, so the prompt cache keeps hitting and no other session ever sees the tool.
|
|
80
|
+
async function applyMessageMode(event: { sessionID: string; tools: Record<string, unknown>; system: Array<{ type: 'text'; text: string }> }) {
|
|
81
|
+
const info = await marker(event.sessionID)
|
|
82
|
+
const messageMode = info?.root === true && info.fields.get('messageTool') === true
|
|
83
|
+
if (!messageMode) delete event.tools[MESSAGE_TOOL]
|
|
84
|
+
else event.system.push({ type: 'text', text: MESSAGE_MODE_INSTRUCTIONS })
|
|
85
|
+
return info
|
|
86
|
+
}
|
|
87
|
+
await ctx.session.hook('compaction', async (event) => {
|
|
88
|
+
await applyMessageMode(event)
|
|
89
|
+
})
|
|
90
|
+
await ctx.session.hook('generate', async (event) => {
|
|
91
|
+
await applyMessageMode(event)
|
|
92
|
+
})
|
|
61
93
|
await ctx.session.hook('context', async (event) => {
|
|
62
94
|
// The only prompt change for every session, TUI included. The ID never
|
|
63
95
|
// changes in a session, so the cached prompt prefix stays stable.
|
|
64
96
|
event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
|
|
65
|
-
|
|
97
|
+
const info = await applyMessageMode(event)
|
|
98
|
+
if (!info) return
|
|
66
99
|
// The <discord-user> context of each prompt, built from its metadata, so the
|
|
67
100
|
// stored text stays clean for the TUI and other clients. Same metadata, same
|
|
68
101
|
// text on every request: the cached prompt prefix stays stable.
|
|
@@ -92,8 +125,8 @@ export default Plugin.define({
|
|
|
92
125
|
await ctx.tool.hook('execute.before', async (event) => {
|
|
93
126
|
if (event.tool !== 'shell' || !event.input || typeof event.input !== 'object' || Array.isArray(event.input)) return
|
|
94
127
|
const info = await marker(event.sessionID)
|
|
95
|
-
const dataDir = info?.get('dataDir')
|
|
96
|
-
const lockPort = info?.get('lockPort')
|
|
128
|
+
const dataDir = info?.fields.get('dataDir')
|
|
129
|
+
const lockPort = info?.fields.get('lockPort')
|
|
97
130
|
if (typeof dataDir !== 'string' || typeof lockPort !== 'number') return
|
|
98
131
|
if (!('command' in event.input) || typeof event.input.command !== 'string') return
|
|
99
132
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
|
|
@@ -102,7 +135,7 @@ export default Plugin.define({
|
|
|
102
135
|
// File edits feed `kimaki session editors` (v1-compatible JSONL index).
|
|
103
136
|
await ctx.tool.hook('execute.after', async (event) => {
|
|
104
137
|
if (event.status !== 'completed' || !fileEditTool(event.tool)) return
|
|
105
|
-
const dataDir = (await marker(event.sessionID))?.get('dataDir')
|
|
138
|
+
const dataDir = (await marker(event.sessionID))?.fields.get('dataDir')
|
|
106
139
|
if (typeof dataDir !== 'string') return
|
|
107
140
|
// Plugins stay silent: a failed append only loses one index entry.
|
|
108
141
|
const recorded = await recordToolEdits({ dataDir, directory: ctx.location.directory, sessionId: event.sessionID, tool: event.tool, input: event.input })
|
package/src/sessions.ts
CHANGED
|
@@ -12,6 +12,7 @@ import * as orm from 'drizzle-orm'
|
|
|
12
12
|
import {
|
|
13
13
|
cliContext,
|
|
14
14
|
globalModel,
|
|
15
|
+
markerIsCurrent,
|
|
15
16
|
oc,
|
|
16
17
|
parseModel,
|
|
17
18
|
projectOf,
|
|
@@ -273,7 +274,7 @@ export async function ensureSessionMarker(
|
|
|
273
274
|
const info = await oc(bot, `get session ${sessionId}`, (client) => client.session.get({ sessionID: sessionId }))
|
|
274
275
|
if (info instanceof Error) return info
|
|
275
276
|
const marker = readMarker(info.metadata)
|
|
276
|
-
if (marker &&
|
|
277
|
+
if (marker && markerIsCurrent(bot, marker)) return
|
|
277
278
|
// Instructions first: the marker means "set up", so a failure here is retried on the next input.
|
|
278
279
|
if (!marker) {
|
|
279
280
|
logger.log(`adopting legacy session ${sessionId} of thread ${thread.id}`)
|
|
@@ -303,7 +304,7 @@ export async function refreshCliContext(bot: Bot) {
|
|
|
303
304
|
}
|
|
304
305
|
const marker = readMarker(info.metadata)
|
|
305
306
|
if (!marker) continue
|
|
306
|
-
if (
|
|
307
|
+
if (markerIsCurrent(bot, marker)) continue
|
|
307
308
|
const metadata = { ...info.metadata, kimaki: { ...marker.fields, ...cliContext(bot) } }
|
|
308
309
|
const result = await oc(bot, 'refresh Kimaki CLI context', (client) => client.session.update({ sessionID: sessionId, metadata }))
|
|
309
310
|
if (result instanceof Error) return result
|
package/src/slash-commands.ts
CHANGED
|
@@ -461,7 +461,7 @@ export function registerSlashCommands(
|
|
|
461
461
|
if (project instanceof Error) return logger.warn(project)
|
|
462
462
|
if (!project) return
|
|
463
463
|
const guild = interaction.guild ?? (await discord.guilds.fetch(interaction.guildId).catch(() => null))
|
|
464
|
-
if (!guild || !(await canUseKimaki({ guild, userId: interaction.user.id }))) {
|
|
464
|
+
if (!guild || !(await canUseKimaki(bot, { guild, userId: interaction.user.id }))) {
|
|
465
465
|
if (interaction.isAutocomplete()) return respondChoices(interaction, [])
|
|
466
466
|
if (interaction.isRepliable()) {
|
|
467
467
|
await interaction.reply({ content: 'You do not have permission to use Kimaki', flags: MessageFlags.Ephemeral })
|
package/src/system-prompt.ts
CHANGED
|
@@ -645,6 +645,44 @@ export function turnContext(metadata: Readonly<Record<string, unknown>> | undefi
|
|
|
645
645
|
return `${files}<discord-user name="${escapeAttribute(username)}" user-id="${userId}" message-id="${messageId}" thread-id="${threadId}" thread-name="${escapeAttribute(threadName)}" />`
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
+
// `kimaki --message-tool`: root sessions get this tool. Discord shows only its text;
|
|
649
|
+
// plain text parts stay visible in the OpenCode TUI and other clients.
|
|
650
|
+
// Wording adapted from Grok Bot's SendMessage tool.
|
|
651
|
+
export const MESSAGE_TOOL = 'send_message'
|
|
652
|
+
|
|
653
|
+
export const MESSAGE_TOOL_DESCRIPTION = dedent`
|
|
654
|
+
Send a concise reply to the user in the Discord thread. Discord shows only the text of send_message calls, not your plain text. Keep writing your normal plain text replies: they stay visible in the OpenCode terminal UI. Then repeat the part the Discord user needs as a short send_message, also for short replies like "Done" or "Yes, it does".
|
|
655
|
+
|
|
656
|
+
If you end a turn where the user waits on you without calling send_message, Discord shows silence.
|
|
657
|
+
|
|
658
|
+
Be very concise. Send meaningful beats only: a real result, a decision, a blocker, a change of plan. Do not narrate routine steps, retries or minor snags. Prefer fewer, higher-signal messages over a play-by-play, but never vanish into a long silent run.
|
|
659
|
+
|
|
660
|
+
An opening acknowledgement does not deliver the result. If you ran something for the user, send the actual result before you end the turn.
|
|
661
|
+
|
|
662
|
+
\`text\` is Discord markdown, shown like a normal message: headings, lists, code blocks, tables and <callout> blocks work. Send files with \`kimaki upload-to-discord\`, never through this tool. After your final send_message, end the turn.
|
|
663
|
+
`
|
|
664
|
+
|
|
665
|
+
export const MESSAGE_TOOL_INPUT = {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: { text: { type: 'string', description: 'Markdown shown to the user in Discord. Keep it short.' } },
|
|
668
|
+
required: ['text'],
|
|
669
|
+
additionalProperties: false,
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Same text for the whole session (the prompt cache keeps hitting). It overrides
|
|
673
|
+
// the "Discord output" rules of the instruction entry, which assume Discord shows text parts.
|
|
674
|
+
export const MESSAGE_MODE_INSTRUCTIONS = dedent`
|
|
675
|
+
## Discord shows only send_message
|
|
676
|
+
|
|
677
|
+
This overrides every rule above about what Discord shows: Discord does NOT show your plain text. Only the \`text\` of \`send_message\` tool calls reaches the Discord thread. The same session can also be open in the OpenCode terminal UI, which shows your plain text normally.
|
|
678
|
+
|
|
679
|
+
- Write your plain text replies as usual, in full. They are for the terminal UI.
|
|
680
|
+
- Then repeat a concise version as send_message, in Discord markdown: every reply, question and final answer, even one word of small talk.
|
|
681
|
+
- Where these instructions say "write the text first" before \`kimaki buttons\`, \`kimaki upload-request\` or \`kimaki sleep\`, call send_message with that text first.
|
|
682
|
+
- Never end a turn with a reply only in plain text.
|
|
683
|
+
- Never mention send_message, tool names or this rule to the user.
|
|
684
|
+
`
|
|
685
|
+
|
|
648
686
|
export type VoiceCallUser = { id: string; username: string }
|
|
649
687
|
export type VoiceCallProject = { channelId: string; channelName: string; directory: string }
|
|
650
688
|
// skill.list entry of OpenCode's global location; path is the absolute SKILL.md.
|
package/src/test/harness.ts
CHANGED
|
@@ -499,6 +499,7 @@ export async function startTestBot({
|
|
|
499
499
|
analytics,
|
|
500
500
|
clock,
|
|
501
501
|
schedulerIntervalMs,
|
|
502
|
+
messageTool,
|
|
502
503
|
}: {
|
|
503
504
|
dataDir: string
|
|
504
505
|
twin: TestTwin
|
|
@@ -519,6 +520,7 @@ export async function startTestBot({
|
|
|
519
520
|
// Scheduling tests pass manualClock() and null, then call bot.scheduler.runDueTasks().
|
|
520
521
|
clock?: Clock
|
|
521
522
|
schedulerIntervalMs?: number | null
|
|
523
|
+
messageTool?: boolean
|
|
522
524
|
}): Promise<BotHandle> {
|
|
523
525
|
// What credential resolution saves in production; `kimaki project add` reads it.
|
|
524
526
|
const credentials: Credentials = twin.discord.botToken.includes(':')
|
|
@@ -547,6 +549,7 @@ export async function startTestBot({
|
|
|
547
549
|
analytics: analytics ?? disabledAnalytics,
|
|
548
550
|
...(clock && { clock }),
|
|
549
551
|
...(schedulerIntervalMs !== undefined && { schedulerIntervalMs }),
|
|
552
|
+
...(messageTool && { messageTool }),
|
|
550
553
|
...(realtimeBaseUrls && { realtimeBaseUrls }),
|
|
551
554
|
transcriptionBaseUrls: {
|
|
552
555
|
...(geminiBaseUrl && { gemini: geminiBaseUrl }),
|
package/src/test/replay.ts
CHANGED
|
@@ -31,7 +31,7 @@ test('tools: names from tool.input.started, failed tool lines, read-only tools h
|
|
|
31
31
|
test('tools at text verbosity: text, edits and errors only', () => {
|
|
32
32
|
const { effects } = replay({
|
|
33
33
|
events: loadFixture('tools.events.jsonl'),
|
|
34
|
-
prefs: { verbosity: 'text', contextLimits: {} },
|
|
34
|
+
prefs: { verbosity: 'text', contextLimits: {}, messageTool: false },
|
|
35
35
|
})
|
|
36
36
|
expect(effectLines(effects)).toMatchInlineSnapshot(`
|
|
37
37
|
[
|
|
@@ -324,7 +324,7 @@ test('permission: two requests, the rejected one fails its tool call', () => {
|
|
|
324
324
|
test('shell-background: background shell line even without side effects, finished line when its job ends', () => {
|
|
325
325
|
const events = loadFixture('shell-background.events.jsonl')
|
|
326
326
|
const view = emptyView({ sessionId: 'ses_ee56d6caeffech0rMI5NTnkky1', channelId: 'channel', directory: '/project', isNew: false })
|
|
327
|
-
const { effects, view: end } = replay({ events, view, prefs: { verbosity: 'tools', contextLimits: {} } })
|
|
327
|
+
const { effects, view: end } = replay({ events, view, prefs: { verbosity: 'tools', contextLimits: {}, messageTool: false } })
|
|
328
328
|
expect(effectLines(effects)).toMatchInlineSnapshot(`
|
|
329
329
|
[
|
|
330
330
|
"» **user:** start a task in background that runs sleep 100",
|
|
@@ -55,6 +55,37 @@ test('text-only turn: banner, text, typing, footer with context percent', () =>
|
|
|
55
55
|
expect(view.turn).toBe(null)
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
test('message mode: send_message shows as markdown, plain text only when the turn sent nothing', () => {
|
|
59
|
+
const turn = loadFixture('tools.events.jsonl').filter((event) => !event.type.startsWith('session.tool.'))
|
|
60
|
+
const prefs = { ...DEFAULT_PREFS, messageTool: true }
|
|
61
|
+
const textIndex = turn.findIndex((event) => event.type === 'session.text.ended')
|
|
62
|
+
const text = turn[textIndex]!
|
|
63
|
+
if (text.type !== 'session.text.ended') throw new Error('Expected text')
|
|
64
|
+
const call = { sessionID: text.data.sessionID, assistantMessageID: text.data.assistantMessageID, id: 'call_message' }
|
|
65
|
+
const durable = { aggregateID: text.data.sessionID, seq: 1, version: 1 as const }
|
|
66
|
+
const message: V2Event[] = [
|
|
67
|
+
{ id: 'evt_message_input', created: text.created, durable, type: 'session.tool.input.started', data: { ...call, name: 'send_message' } },
|
|
68
|
+
{ id: 'evt_message_called', created: text.created, durable, type: 'session.tool.called', data: { ...call, executed: false, input: { text: 'Visible **reply**' } } },
|
|
69
|
+
]
|
|
70
|
+
const sent = replay({ prefs, events: [...turn.slice(0, textIndex), ...message, ...turn.slice(textIndex)] })
|
|
71
|
+
const silent = replay({ prefs, events: turn })
|
|
72
|
+
const lines = (effects: typeof sent.effects) => effectLines(effects).filter((line) => !line.startsWith('»') && !line.startsWith('[typing'))
|
|
73
|
+
expect({ sent: lines(sent.effects), silent: lines(silent.effects) }).toMatchInlineSnapshot(`
|
|
74
|
+
{
|
|
75
|
+
"sent": [
|
|
76
|
+
"-# *using openai/gpt-6-luna low ⋅ build*",
|
|
77
|
+
"Visible **reply**",
|
|
78
|
+
"-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
|
|
79
|
+
],
|
|
80
|
+
"silent": [
|
|
81
|
+
"-# *using openai/gpt-6-luna low ⋅ build*",
|
|
82
|
+
"Done. \`tmp-events/hello.txt\` contains \`hello world\`.",
|
|
83
|
+
"-# *project ⋅ main ⋅ 17s ⋅ 6% ⋅ gpt-6-luna*",
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
`)
|
|
87
|
+
})
|
|
88
|
+
|
|
58
89
|
test('kimaki sleep in a turn makes its footer silent, the next turn notifies again', () => {
|
|
59
90
|
const turn = loadFixture('tools.events.jsonl').filter((event) => !event.type.startsWith('session.tool.'))
|
|
60
91
|
const end = turn.findIndex((event) => event.type === 'session.execution.succeeded')
|
package/src/thread-reducer.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { closePermission, hydratePermissions, showPermission, STATUS, type PendingPermission } from './permissions.ts'
|
|
56
56
|
import { closeForm, formatAnswer, hydrateForms, showForm, withAnswer, type PendingForm } from './questions.ts'
|
|
57
57
|
import { cancelQueued, deliverQueued, enqueueInput, hydrateQueue, promoteQueued, queuedItems, type PendingInput } from './queue.ts'
|
|
58
|
+
import { MESSAGE_TOOL } from './system-prompt.ts'
|
|
58
59
|
|
|
59
60
|
export type Turn = {
|
|
60
61
|
startedAt: number
|
|
@@ -64,6 +65,14 @@ export type Turn = {
|
|
|
64
65
|
tokens: number
|
|
65
66
|
// `kimaki sleep` ran in this turn: the session wakes later, so the footer is silent.
|
|
66
67
|
slept: boolean
|
|
68
|
+
// Message mode (Prefs.messageTool): a send_message call was shown in this turn.
|
|
69
|
+
messaged: boolean
|
|
70
|
+
// Message mode: the last hidden text, shown at the end if the turn sent no message.
|
|
71
|
+
unsent: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function newTurn(at: number): Turn {
|
|
75
|
+
return { startedAt: at, model: null, agent: null, tokens: 0, slept: false, messaged: false, unsent: null }
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export type Child = {
|
|
@@ -186,6 +195,8 @@ export type Prefs = {
|
|
|
186
195
|
verbosity: Verbosity
|
|
187
196
|
// "providerID/modelID" -> context window size in tokens.
|
|
188
197
|
contextLimits: Readonly<Record<string, number>>
|
|
198
|
+
// `kimaki --message-tool`: Discord hides root plain text (kept for the TUI), only send_message shows.
|
|
199
|
+
messageTool: boolean
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
const RETRY_NOTICE_INTERVAL_MS = 10_000
|
|
@@ -281,6 +292,9 @@ function contextPercent({ turn, prefs }: { turn: Draft<Turn>; prefs: Prefs }): n
|
|
|
281
292
|
|
|
282
293
|
// The footer closing the answer of `turn` at `at`.
|
|
283
294
|
function emitFooter({ draft, prefs, emit, turn, at, notify }: Context & { turn: Draft<Turn>; at: number; notify: boolean }) {
|
|
295
|
+
// Message mode: a model that never called send_message would leave the user in silence.
|
|
296
|
+
if (!turn.messaged && turn.unsent) emit({ type: 'markdown', text: turn.unsent, blankLineBefore: draft.lastKind === 'tool' })
|
|
297
|
+
turn.unsent = null
|
|
284
298
|
// No model step ran (a compaction-only execution): nothing to summarize.
|
|
285
299
|
if (!turn.model) return
|
|
286
300
|
// A child still runs: the answer comes later.
|
|
@@ -346,11 +360,11 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
346
360
|
emit({ type: 'send', text: asSubtext(`Working directory changed to ${event.data.location.directory}`) })
|
|
347
361
|
return
|
|
348
362
|
case 'session.execution.started':
|
|
349
|
-
draft.turn ??=
|
|
363
|
+
draft.turn ??= newTurn(event.created)
|
|
350
364
|
return
|
|
351
365
|
case 'session.step.started': {
|
|
352
366
|
const model = { providerID: event.data.model.providerID, id: event.data.model.id }
|
|
353
|
-
draft.turn ??=
|
|
367
|
+
draft.turn ??= newTurn(event.created)
|
|
354
368
|
draft.turn.model = model
|
|
355
369
|
draft.turn.agent = event.data.agent
|
|
356
370
|
if (draft.bannerPending) emit({ type: 'send', text: formatBanner({ model: event.data.model, agent: event.data.agent }) })
|
|
@@ -373,6 +387,10 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
373
387
|
case 'session.text.ended': {
|
|
374
388
|
const text = event.data.text.trim()
|
|
375
389
|
if (!text) return
|
|
390
|
+
if (prefs.messageTool && draft.turn) {
|
|
391
|
+
draft.turn.unsent = text
|
|
392
|
+
return
|
|
393
|
+
}
|
|
376
394
|
emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
|
|
377
395
|
draft.lastKind = 'text'
|
|
378
396
|
return
|
|
@@ -434,6 +452,7 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
434
452
|
emitFooter({ ...context, turn: draft.turn, at: event.created, notify: false })
|
|
435
453
|
draft.turn.startedAt = event.created
|
|
436
454
|
draft.turn.slept = false
|
|
455
|
+
draft.turn.messaged = false
|
|
437
456
|
draft.lastKind = null
|
|
438
457
|
}
|
|
439
458
|
return deliverQueued({ draft, emit, inboxID: event.data.inboxID })
|
|
@@ -494,6 +513,15 @@ function applyTool(context: Context & { event: V2Event; label: string | undefine
|
|
|
494
513
|
if (reused) Object.assign(reused, { ...call, agent: call.agent || reused.agent })
|
|
495
514
|
}
|
|
496
515
|
draft.tools[key] = tool
|
|
516
|
+
// The plugin offers send_message to root sessions only (MESSAGE_TOOL).
|
|
517
|
+
if (name === MESSAGE_TOOL && label === undefined) {
|
|
518
|
+
const text = stringInput(input, 'text').trim()
|
|
519
|
+
if (draft.turn) draft.turn.messaged = true
|
|
520
|
+
if (!text) return
|
|
521
|
+
context.emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
|
|
522
|
+
draft.lastKind = 'text'
|
|
523
|
+
return
|
|
524
|
+
}
|
|
497
525
|
if (render && isToolVisible({ name, input }, prefs.verbosity)) toolLine(context, formatToolLine({ name, input }, { label }))
|
|
498
526
|
return
|
|
499
527
|
}
|
|
@@ -613,7 +641,7 @@ function applyKimaki({ draft, event, prefs, emit }: Context & { event: KimakiEve
|
|
|
613
641
|
const active = new Set(event.activeSessionIds)
|
|
614
642
|
for (const [id, child] of Object.entries(draft.children)) child.running = active.has(id)
|
|
615
643
|
if (!active.has(draft.sessionId)) draft.turn = null
|
|
616
|
-
else draft.turn ??=
|
|
644
|
+
else draft.turn ??= newTurn(event.at)
|
|
617
645
|
// Nothing runs: tool calls whose end was missed while disconnected are over.
|
|
618
646
|
if (!isBusy(draft)) draft.tools = {}
|
|
619
647
|
for (const session of event.sessions) {
|
|
@@ -645,9 +673,13 @@ export function replayEffects({
|
|
|
645
673
|
const blocks = messages.flatMap((message): Block[] => {
|
|
646
674
|
if (message.type !== 'assistant') return []
|
|
647
675
|
return message.content.flatMap((part): Block[] => {
|
|
648
|
-
if (part.type === 'text') return part.text.trim() ? [{ kind: 'text', text: part.text.trim() }] : []
|
|
676
|
+
if (part.type === 'text') return part.text.trim() && !prefs.messageTool ? [{ kind: 'text', text: part.text.trim() }] : []
|
|
649
677
|
if (part.type !== 'tool' || part.state.status === 'streaming') return []
|
|
650
678
|
const { input, metadata } = part.state
|
|
679
|
+
if (part.name === MESSAGE_TOOL) {
|
|
680
|
+
const text = stringInput(input, 'text').trim()
|
|
681
|
+
return text ? [{ kind: 'text', text }] : []
|
|
682
|
+
}
|
|
651
683
|
const isExecute = part.name === 'execute'
|
|
652
684
|
const lines = [{ name: part.name, input }, ...(isExecute ? executeCalls(metadata) : [])]
|
|
653
685
|
.filter((call) => isToolVisible(call, prefs.verbosity))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { detectPortFromText, routeName } from './tunnel.ts'
|
|
3
|
+
|
|
4
|
+
test('detects the dev server port and skips noise lines', () => {
|
|
5
|
+
const vite = '\u001b[33mPort 5173 is in use, trying another one...\u001b[39m\n ➜ Local: \u001b[36mhttp://localhost:\u001b[1m5174\u001b[22m/\u001b[39m\n'
|
|
6
|
+
expect({
|
|
7
|
+
vite: detectPortFromText(vite),
|
|
8
|
+
next: detectPortFromText(' - Local: http://127.0.0.1:3000'),
|
|
9
|
+
bare: detectPortFromText('Server running at 0.0.0.0:4321'),
|
|
10
|
+
inspector: detectPortFromText('Debugger listening on ws://127.0.0.1:9229/abc\nready on http://localhost:4173'),
|
|
11
|
+
outOfRange: detectPortFromText('listening on localhost:70000'),
|
|
12
|
+
portOnly: detectPortFromText('Port 5173 is in use'),
|
|
13
|
+
}).toMatchInlineSnapshot(`
|
|
14
|
+
{
|
|
15
|
+
"bare": 4321,
|
|
16
|
+
"inspector": 4173,
|
|
17
|
+
"next": 3000,
|
|
18
|
+
"outOfRange": null,
|
|
19
|
+
"portOnly": null,
|
|
20
|
+
"vite": 5174,
|
|
21
|
+
}
|
|
22
|
+
`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('route name: explicit, then tuistory session, then random', () => {
|
|
26
|
+
expect({
|
|
27
|
+
explicit: routeName({ name: 'docs', tuistorySessionId: 'abc123' }),
|
|
28
|
+
invalid: String(routeName({ name: 'Bad_Name' })),
|
|
29
|
+
tuistory: routeName({ tuistorySessionId: 'ABC123' }),
|
|
30
|
+
random: String(routeName({})).length,
|
|
31
|
+
}).toMatchInlineSnapshot(`
|
|
32
|
+
{
|
|
33
|
+
"explicit": "docs",
|
|
34
|
+
"invalid": "Error: Invalid tunnel ID "Bad_Name": use lowercase letters, digits and dashes",
|
|
35
|
+
"random": 16,
|
|
36
|
+
"tuistory": "abc123",
|
|
37
|
+
}
|
|
38
|
+
`)
|
|
39
|
+
})
|