kimaki 1.2.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 +7 -3
- package/dist/bot.js +9 -3
- package/dist/bot.js.map +1 -1
- package/dist/cli/bot.js +10 -5
- 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/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.js +11 -6
- package/dist/ingress.js.map +1 -1
- package/dist/lock-routes.d.ts +14 -14
- package/dist/main.d.ts +1 -0
- package/dist/main.js +2 -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/prompt.d.ts +6 -6
- package/dist/scheduler.d.ts +1 -1
- package/dist/sessions.d.ts +10 -10
- package/dist/sessions.js +3 -3
- package/dist/sessions.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/package.json +5 -5
- package/skills/kimaki-tunnel/SKILL.md +14 -10
- package/src/bot.ts +14 -4
- package/src/cli/bot.ts +9 -5
- package/src/cli-lock.e2e.test.ts +3 -2
- package/src/event-loop.ts +4 -1
- package/src/hello-thread.e2e.test.ts +35 -0
- package/src/ingress.ts +9 -5
- package/src/main.ts +4 -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/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
|
@@ -5,26 +5,30 @@ description: Share a local dev server with the user through a public URL. Use wh
|
|
|
5
5
|
|
|
6
6
|
# Dev servers with tunnel access
|
|
7
7
|
|
|
8
|
-
Localhost URLs are useless from Discord. Wrap the dev server in `kimaki tunnel` to get a public URL, and run it in a named background `tuistory` session so you can wait for output, read logs and stop it later. Name the session descriptively (e.g. `projectname-dev`) and reuse it. Run `tuistory --help` first (or `bunx tuistory` when it is not installed). Invoke `kimaki` directly, not via `npx` or `bunx`.
|
|
8
|
+
Localhost URLs are useless from Discord. Wrap the dev server in `kimaki tunnel` to get a public `https://<name>.<id>.opentunnel.xyz` URL, and run it in a named background `tuistory` session so you can wait for output, read logs and stop it later. Name the session descriptively (e.g. `projectname-dev`) and reuse it. Run `tuistory --help` first (or `bunx tuistory` when it is not installed). Invoke `kimaki` directly, not via `npx` or `bunx`.
|
|
9
9
|
|
|
10
|
-
- Use random
|
|
11
|
-
- `kimaki tunnel` detects the local port from the child output. Pass `--port` only when the server prints no detectable localhost URL
|
|
12
|
-
- `kimaki tunnel` injects `
|
|
10
|
+
- Use random names by default. Pass `-t <name>` only for services that are safe to be publicly discoverable.
|
|
11
|
+
- `kimaki tunnel` detects the local port from the child output. Pass `--port` only when the server prints no detectable localhost URL.
|
|
12
|
+
- `kimaki tunnel` injects `KIMAKI_TUNNEL_URL` into the child process. Wire the app to it so OAuth callbacks, webhook URLs and absolute links use the public URL.
|
|
13
|
+
- TLS ends on this machine and the request reaches the app unchanged. Dev servers that check the `Host` header (Vite) must allow it, e.g. `server.allowedHosts: true` or `['.opentunnel.xyz']`.
|
|
14
|
+
- The first run on a machine creates the tunnel and waits up to a minute for its certificate.
|
|
13
15
|
|
|
14
16
|
```bash
|
|
15
17
|
# start in a named background session, wait for output, then read the tunnel URL
|
|
16
18
|
tuistory launch "kimaki tunnel -- pnpm dev" -s myapp-dev
|
|
17
|
-
tuistory -s myapp-dev wait "/
|
|
19
|
+
tuistory -s myapp-dev wait "/Tunnel: https/" --timeout 90000
|
|
18
20
|
tuistory read -s myapp-dev
|
|
19
21
|
|
|
20
|
-
# pass the public URL to the app (better-auth, Next.js, Vite; node reads process.env.
|
|
21
|
-
tuistory launch "kimaki tunnel -- sh -c 'BETTER_AUTH_URL
|
|
22
|
-
tuistory launch "kimaki tunnel -- sh -c 'APP_URL
|
|
23
|
-
tuistory launch "kimaki tunnel -- sh -c 'VITE_BASE_URL=$TRAFORO_URL exec pnpm dev'" -s myapp-dev
|
|
22
|
+
# pass the public URL to the app (better-auth, Next.js, Vite; node reads process.env.KIMAKI_TUNNEL_URL)
|
|
23
|
+
tuistory launch "kimaki tunnel -- sh -c 'BETTER_AUTH_URL=\$KIMAKI_TUNNEL_URL exec pnpm dev'" -s myapp-dev
|
|
24
|
+
tuistory launch "kimaki tunnel -- sh -c 'APP_URL=\$KIMAKI_TUNNEL_URL exec pnpm dev'" -s myapp-dev
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
+
# fixed name (only for intentionally public-safe services)
|
|
26
27
|
tuistory launch "kimaki tunnel -t holocron -- pnpm dev" -s holocron-dev
|
|
27
28
|
|
|
29
|
+
# restart the server on a known port and keep the URL
|
|
30
|
+
tuistory launch "kimaki tunnel -t holocron -p 5173 --kill -- pnpm dev --port 5173" -s holocron-dev
|
|
31
|
+
|
|
28
32
|
# list sessions; stop with Ctrl+C, then close
|
|
29
33
|
tuistory sessions
|
|
30
34
|
tuistory -s myapp-dev press ctrl c
|
package/src/bot.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type Bot = {
|
|
|
69
69
|
allowAllUsers: boolean
|
|
70
70
|
// Built-in skill ids (builtin-skills.ts) denied through the permissions of new sessions.
|
|
71
71
|
disabledSkills: readonly string[]
|
|
72
|
+
// `kimaki --message-tool`: root sessions also reply through send_message; Discord hides their plain text.
|
|
73
|
+
messageTool: boolean
|
|
72
74
|
features: BotFeatures
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -134,6 +136,7 @@ export type SessionMarker = {
|
|
|
134
136
|
fields: { readonly [key: string]: JsonValue }
|
|
135
137
|
dataDir: string | null
|
|
136
138
|
lockPort: number | null
|
|
139
|
+
messageTool: boolean
|
|
137
140
|
// Explicit `kimaki send --parent-session`, so /resume keeps the instruction line.
|
|
138
141
|
parentSessionId: string | null
|
|
139
142
|
// The scheduled run stored at session.create, so /resume keeps the same instruction section.
|
|
@@ -147,7 +150,7 @@ function jsonObject(value: JsonValue | undefined): { readonly [key: string]: Jso
|
|
|
147
150
|
export function readMarker(metadata: SessionMetadata | undefined): SessionMarker | null {
|
|
148
151
|
const fields = jsonObject(metadata?.['kimaki'])
|
|
149
152
|
if (!fields) return null
|
|
150
|
-
const { dataDir, lockPort, parentSessionId } = fields
|
|
153
|
+
const { dataDir, lockPort, messageTool, parentSessionId } = fields
|
|
151
154
|
const task = jsonObject(fields['task'])
|
|
152
155
|
const taskId = task?.['id']
|
|
153
156
|
const cronExpr = task?.['cronExpr']
|
|
@@ -156,6 +159,7 @@ export function readMarker(metadata: SessionMetadata | undefined): SessionMarker
|
|
|
156
159
|
fields,
|
|
157
160
|
dataDir: typeof dataDir === 'string' ? dataDir : null,
|
|
158
161
|
lockPort: typeof lockPort === 'number' ? lockPort : null,
|
|
162
|
+
messageTool: messageTool === true,
|
|
159
163
|
parentSessionId: typeof parentSessionId === 'string' ? parentSessionId : null,
|
|
160
164
|
task: typeof taskId === 'number'
|
|
161
165
|
? { id: taskId, cronExpr: typeof cronExpr === 'string' ? cronExpr : null, timezone: typeof timezone === 'string' ? timezone : null }
|
|
@@ -163,9 +167,15 @@ export function readMarker(metadata: SessionMetadata | undefined): SessionMarker
|
|
|
163
167
|
}
|
|
164
168
|
}
|
|
165
169
|
|
|
166
|
-
// The marker fields that
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
// The marker fields that follow this bot run: where the agent's `kimaki` calls
|
|
171
|
+
// go, and whether the plugin gives the session send_message.
|
|
172
|
+
export function cliContext(bot: Pick<Bot, 'dataDir' | 'lockPort' | 'messageTool'>) {
|
|
173
|
+
return { dataDir: bot.dataDir, lockPort: bot.lockPort, messageTool: bot.messageTool }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// False when the session marker was written by another bot run or with another --message-tool.
|
|
177
|
+
export function markerIsCurrent(bot: Pick<Bot, 'dataDir' | 'lockPort' | 'messageTool'>, marker: SessionMarker): boolean {
|
|
178
|
+
return marker.dataDir === bot.dataDir && marker.lockPort === bot.lockPort && marker.messageTool === bot.messageTool
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
// Default model of new sessions in channels without their own (/model "All channels").
|
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'
|
|
@@ -89,6 +89,7 @@ export function registerStartCommand(cli: Goke) {
|
|
|
89
89
|
.option('--restart-onboarding', 'Choose credentials again')
|
|
90
90
|
.option('--worktrees', 'Use a fresh Git worktree for new sessions unless the channel overrides it')
|
|
91
91
|
.option('--allow-all-users', 'Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)')
|
|
92
|
+
.option('--message-tool', 'Discord shows only a concise reply the agent sends with a send_message tool; plain text stays visible in the OpenCode TUI')
|
|
92
93
|
.option('--voice <name>', 'Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it')
|
|
93
94
|
.option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
|
|
94
95
|
.option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
|
|
@@ -219,6 +220,7 @@ export function registerStartCommand(cli: Goke) {
|
|
|
219
220
|
autoWorktrees: Boolean(options.worktrees),
|
|
220
221
|
allowAllUsers: Boolean(options.allowAllUsers),
|
|
221
222
|
disabledSkills,
|
|
223
|
+
messageTool: Boolean(options.messageTool),
|
|
222
224
|
})
|
|
223
225
|
if (bot instanceof Error) failStartup(bot)
|
|
224
226
|
// exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
|
|
@@ -248,6 +250,7 @@ export function registerStartCommand(cli: Goke) {
|
|
|
248
250
|
...(options.worktrees ? ['--worktrees'] : []),
|
|
249
251
|
...(options.allowAllUsers ? ['--allow-all-users'] : []),
|
|
250
252
|
...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
|
|
253
|
+
...(options.messageTool ? ['--message-tool'] : []),
|
|
251
254
|
...(options.noAnalytics ? ['--no-analytics'] : []),
|
|
252
255
|
...(options.machineName ? ['--machine-name', options.machineName] : []),
|
|
253
256
|
] })
|
|
@@ -283,9 +286,9 @@ export function registerLoginCommands(cli: Goke) {
|
|
|
283
286
|
|
|
284
287
|
// tunnel, tts
|
|
285
288
|
export function registerToolCommands(cli: Goke) {
|
|
286
|
-
cli.command('tunnel', 'Run a command and expose its local port with a public URL. The child gets
|
|
289
|
+
cli.command('tunnel', 'Run a command and expose its local port with a public opentunnel.xyz URL. The child gets KIMAKI_TUNNEL_URL')
|
|
287
290
|
.option('-p, --port <port>', 'Local port (default: read from the command output)')
|
|
288
|
-
.option('-t, --tunnel-id <id>', 'Fixed
|
|
291
|
+
.option('-t, --tunnel-id <id>', 'Fixed subdomain (default: random). Only for public-safe services')
|
|
289
292
|
.option('--host <host>', 'Local host (default: localhost)')
|
|
290
293
|
.option('-k, --kill', 'Kill the process on --port first')
|
|
291
294
|
.example('kimaki tunnel -- pnpm dev')
|
|
@@ -294,8 +297,9 @@ export function registerToolCommands(cli: Goke) {
|
|
|
294
297
|
const port = options.port ? Number(options.port) : undefined
|
|
295
298
|
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) fail(new Error(`Invalid --port ${options.port}`))
|
|
296
299
|
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,
|
|
300
|
+
const { runTunnel } = await import('../tunnel.ts')
|
|
301
|
+
const result = await runTunnel({ port, command: command.length > 0 ? command : undefined, name: options.tunnelId, host: options.host, kill: options.kill })
|
|
302
|
+
if (result instanceof Error) fail(result)
|
|
299
303
|
})
|
|
300
304
|
|
|
301
305
|
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
|
|
|
@@ -376,6 +376,7 @@ test('CLI help documents the supported P7 commands', async () => {
|
|
|
376
376
|
--restart-onboarding Choose credentials again
|
|
377
377
|
--worktrees Use a fresh Git worktree for new sessions unless the channel overrides it
|
|
378
378
|
--allow-all-users Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)
|
|
379
|
+
--message-tool Discord shows only a concise reply the agent sends with a send_message tool; plain text stays visible in the OpenCode TUI
|
|
379
380
|
--voice <name> Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it
|
|
380
381
|
--disable-skills <list> Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)
|
|
381
382
|
--no-analytics Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)
|
package/src/event-loop.ts
CHANGED
|
@@ -67,6 +67,7 @@ export function createEventLoop({
|
|
|
67
67
|
effects,
|
|
68
68
|
recorder,
|
|
69
69
|
analytics,
|
|
70
|
+
messageTool,
|
|
70
71
|
onRunEnded,
|
|
71
72
|
onTranscriptDue,
|
|
72
73
|
}: {
|
|
@@ -76,6 +77,8 @@ export function createEventLoop({
|
|
|
76
77
|
effects: EffectsRunner
|
|
77
78
|
recorder: EventRecorder
|
|
78
79
|
analytics: Analytics
|
|
80
|
+
// `kimaki --message-tool`: plain text of root sessions stays hidden (Prefs.messageTool).
|
|
81
|
+
messageTool: boolean
|
|
79
82
|
// Every finished or failed run of a thread's root session (voice call notifications).
|
|
80
83
|
onRunEnded: (run: RunEnded) => void
|
|
81
84
|
// A root session stopped running (also after an interrupt): its transcript file is stale.
|
|
@@ -122,7 +125,7 @@ export function createEventLoop({
|
|
|
122
125
|
if (!view) return
|
|
123
126
|
const verbosity = state.verbosity[view.channelId] ?? verbosityFromV1(null)
|
|
124
127
|
const contextLimits = modelLimits.get(view.directory)?.limits ?? {}
|
|
125
|
-
const result = reduce({ view, event, prefs: { verbosity, contextLimits } })
|
|
128
|
+
const result = reduce({ view, event, prefs: { verbosity, contextLimits, messageTool } })
|
|
126
129
|
if (result.view !== view) store.setState((current) => ({ threads: { ...current.threads, [threadId]: result.view } }))
|
|
127
130
|
effects.run(threadId, result.effects)
|
|
128
131
|
if (event.type === 'session.moved' && event.data.sessionID === view.sessionId) void loadModelLimits(event.data.location.directory)
|
|
@@ -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
|
@@ -68,6 +68,8 @@ export type StartBotOptions = {
|
|
|
68
68
|
allowAllUsers?: boolean
|
|
69
69
|
// Built-in skill ids denied in new sessions (builtin-skills.ts).
|
|
70
70
|
disabledSkills?: readonly string[]
|
|
71
|
+
// `kimaki --message-tool`: agents also reply through send_message; Discord hides their plain text.
|
|
72
|
+
messageTool?: boolean
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export type BotHandle = Bot & {
|
|
@@ -146,7 +148,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
146
148
|
if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
|
|
147
149
|
})
|
|
148
150
|
}
|
|
149
|
-
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 })
|
|
150
152
|
const loaded = await eventLoop.load()
|
|
151
153
|
if (loaded instanceof Error) return loaded
|
|
152
154
|
// Before the service is used, so a service started by ensure() loads it at once.
|
|
@@ -185,6 +187,7 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
185
187
|
autoWorktrees: options.autoWorktrees ?? false,
|
|
186
188
|
allowAllUsers: options.allowAllUsers ?? false,
|
|
187
189
|
disabledSkills: options.disabledSkills ?? [],
|
|
190
|
+
messageTool: options.messageTool ?? false,
|
|
188
191
|
features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
|
|
189
192
|
}
|
|
190
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/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')
|