kimaki 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autostart.js +15 -3
- package/dist/autostart.js.map +1 -1
- package/dist/bot.d.ts +7 -5
- package/dist/bot.js.map +1 -1
- package/dist/builtin-skills.d.ts +18 -0
- package/dist/builtin-skills.js +51 -0
- package/dist/builtin-skills.js.map +1 -0
- package/dist/builtin-skills.test.d.ts +1 -0
- package/dist/builtin-skills.test.js +28 -0
- package/dist/builtin-skills.test.js.map +1 -0
- package/dist/cli/bot.js +10 -0
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/discord.js +1 -1
- package/dist/cli/discord.js.map +1 -1
- package/dist/cli/session.js +17 -53
- package/dist/cli/session.js.map +1 -1
- package/dist/cli-discord.e2e.test.js +19 -1
- package/dist/cli-discord.e2e.test.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +7 -6
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/cli-read.e2e.test.js +8 -3
- package/dist/cli-read.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/login-commands.d.ts +1 -1
- package/dist/commands/preference-commands.d.ts +1 -1
- package/dist/commands/preference-commands.js +17 -3
- package/dist/commands/preference-commands.js.map +1 -1
- package/dist/commands/worktree-commands.d.ts +1 -1
- package/dist/effects.js +30 -6
- package/dist/effects.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/event-loop.d.ts +2 -1
- package/dist/event-loop.js +5 -1
- package/dist/event-loop.js.map +1 -1
- package/dist/ingress.d.ts +1 -1
- package/dist/ingress.js +14 -11
- package/dist/ingress.js.map +1 -1
- package/dist/lock-routes.d.ts +12 -12
- package/dist/login.e2e.test.js +2 -2
- package/dist/main.d.ts +2 -0
- package/dist/main.js +223 -151
- package/dist/main.js.map +1 -1
- package/dist/opencode-server.js +15 -11
- package/dist/opencode-server.js.map +1 -1
- package/dist/opencode-server.test.js +1 -1
- package/dist/opencode-server.test.js.map +1 -1
- package/dist/plugin/index.js +19 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/read-media.d.ts +23 -0
- package/dist/plugin/read-media.js +72 -0
- package/dist/plugin/read-media.js.map +1 -0
- package/dist/plugin.e2e.test.js +6 -1
- package/dist/plugin.e2e.test.js.map +1 -1
- package/dist/prompt.d.ts +6 -6
- package/dist/prompt.js +10 -11
- package/dist/prompt.js.map +1 -1
- package/dist/scheduler.d.ts +1 -1
- package/dist/session-events.d.ts +29 -1
- package/dist/session-events.js +70 -0
- package/dist/session-events.js.map +1 -1
- package/dist/sessions.d.ts +10 -10
- package/dist/sessions.js +3 -2
- package/dist/sessions.js.map +1 -1
- package/dist/slash-commands.js +4 -3
- package/dist/slash-commands.js.map +1 -1
- package/dist/system-prompt.d.ts +13 -2
- package/dist/system-prompt.js +41 -64
- package/dist/system-prompt.js.map +1 -1
- package/dist/voice-attachments.e2e.test.js +6 -3
- package/dist/voice-attachments.e2e.test.js.map +1 -1
- package/dist/voice-call.js +1 -1
- package/dist/voice-call.js.map +1 -1
- package/package.json +7 -6
- package/skills/kimaki-debug/SKILL.md +44 -0
- package/skills/kimaki-tunnel/SKILL.md +34 -0
- package/src/autostart.ts +15 -3
- package/src/bot.ts +4 -0
- package/src/builtin-skills.test.ts +29 -0
- package/src/builtin-skills.ts +59 -0
- package/src/cli/bot.ts +9 -0
- package/src/cli/discord.ts +1 -1
- package/src/cli/session.ts +14 -47
- package/src/cli-discord.e2e.test.ts +18 -1
- package/src/cli-lock.e2e.test.ts +7 -6
- package/src/cli-read.e2e.test.ts +8 -3
- package/src/cli-sessions.e2e.test.ts +2 -4
- package/src/commands/preference-commands.ts +19 -6
- package/src/effects.ts +40 -6
- package/src/errors.ts +1 -1
- package/src/event-loop.ts +9 -0
- package/src/ingress.ts +16 -11
- package/src/login.e2e.test.ts +2 -2
- package/src/main.ts +12 -2
- package/src/opencode-server.test.ts +1 -1
- package/src/opencode-server.ts +12 -9
- package/src/plugin/index.ts +20 -1
- package/src/plugin.e2e.test.ts +6 -1
- package/src/prompt.ts +10 -10
- package/src/session-events.ts +83 -0
- package/src/sessions.ts +3 -2
- package/src/slash-commands.ts +4 -3
- package/src/system-prompt.ts +43 -64
- package/src/voice-attachments.e2e.test.ts +6 -3
- package/src/voice-call.ts +1 -1
- package/src/plugin/session-id/index.ts +0 -19
package/src/ingress.ts
CHANGED
|
@@ -39,9 +39,13 @@ import { isVoiceAttachment, parseVoiceMessage, transcribe, voiceMediaType, type
|
|
|
39
39
|
|
|
40
40
|
const logger = createLogger('INGRESS')
|
|
41
41
|
|
|
42
|
-
// Owner, Administrator, Manage Server, or a role named "Kimaki"
|
|
43
|
-
// "no-kimaki" always denies.
|
|
44
|
-
|
|
42
|
+
// Owner, Administrator, Manage Server, or a role named "Kimaki"; with
|
|
43
|
+
// --allow-all-users every member. A role named "no-kimaki" always denies.
|
|
44
|
+
// Missing member data fails closed.
|
|
45
|
+
export async function canUseKimaki(
|
|
46
|
+
bot: Pick<Bot, 'allowAllUsers'>,
|
|
47
|
+
{ guild, userId }: { guild: Guild; userId: string },
|
|
48
|
+
): Promise<boolean> {
|
|
45
49
|
const member = await guild.members.fetch({ user: userId, force: true }).catch((error: Error) => error)
|
|
46
50
|
if (member instanceof Error) {
|
|
47
51
|
// Still denied, but say why: a REST failure must not look like a missing role.
|
|
@@ -49,19 +53,20 @@ export async function canUseKimaki({ guild, userId }: { guild: Guild; userId: st
|
|
|
49
53
|
return false
|
|
50
54
|
}
|
|
51
55
|
if (!(member instanceof GuildMember)) return false
|
|
52
|
-
return memberCanUseKimaki(member)
|
|
56
|
+
return memberCanUseKimaki(bot, member)
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
// MESSAGE_CREATE carries the author's current roles: no REST fetch on the hot path.
|
|
56
60
|
// 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 })
|
|
61
|
+
function authorCanUseKimaki(bot: Bot, message: Message<true>): Promise<boolean> {
|
|
62
|
+
if (message.member instanceof GuildMember) return Promise.resolve(memberCanUseKimaki(bot, message.member))
|
|
63
|
+
return canUseKimaki(bot, { guild: message.guild, userId: message.author.id })
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
function memberCanUseKimaki(member: GuildMember): boolean {
|
|
66
|
+
function memberCanUseKimaki(bot: Pick<Bot, 'allowAllUsers'>, member: GuildMember): boolean {
|
|
63
67
|
const roleNames = member.roles.cache.map((role) => role.name.toLowerCase())
|
|
64
68
|
if (roleNames.includes('no-kimaki')) return false
|
|
69
|
+
if (bot.allowAllUsers) return true
|
|
65
70
|
if (member.guild.ownerId === member.id) return true
|
|
66
71
|
if (member.permissions.has(PermissionFlagsBits.Administrator)) return true
|
|
67
72
|
if (member.permissions.has(PermissionFlagsBits.ManageGuild)) return true
|
|
@@ -208,7 +213,7 @@ async function handleMessage(bot: Bot, message: Message) {
|
|
|
208
213
|
if (!project) return
|
|
209
214
|
if (project.channel_type === 'voice') {
|
|
210
215
|
// The text chat of the voice channel talks to the running call.
|
|
211
|
-
if (message.author.bot || thread || !(await authorCanUseKimaki(message))) return
|
|
216
|
+
if (message.author.bot || thread || !(await authorCanUseKimaki(bot, message))) return
|
|
212
217
|
bot.features.voiceCalls.text(bot, {
|
|
213
218
|
guildId: message.guild.id,
|
|
214
219
|
channelId,
|
|
@@ -221,7 +226,7 @@ async function handleMessage(bot: Bot, message: Message) {
|
|
|
221
226
|
if (message.author.id !== bot.discord.user?.id) return
|
|
222
227
|
return handleRemoteEnvelope(bot, { message, channelId, threadId: thread?.id ?? null })
|
|
223
228
|
}
|
|
224
|
-
if (!(await authorCanUseKimaki(message))) {
|
|
229
|
+
if (!(await authorCanUseKimaki(bot, message))) {
|
|
225
230
|
logger.log(`ignoring ${message.author.username}: no Kimaki permission`)
|
|
226
231
|
return
|
|
227
232
|
}
|
|
@@ -317,7 +322,7 @@ export function registerIngress(bot: Bot) {
|
|
|
317
322
|
serialize(message.channelId, async () => {
|
|
318
323
|
const full = message.partial ? await message.fetch().catch(() => null) : message
|
|
319
324
|
if (!full?.guild || !queuedItemFor(bot, { threadId: full.channelId, messageId: full.id })) return
|
|
320
|
-
if (!(await canUseKimaki({ guild: full.guild, userId: full.author.id }))) return
|
|
325
|
+
if (!(await canUseKimaki(bot, { guild: full.guild, userId: full.author.id }))) return
|
|
321
326
|
// The re-queued prompt must carry the message's attachments again.
|
|
322
327
|
const files = await saveAttachments({ dataDir: bot.dataDir, messageId: full.id, attachments: [...full.attachments.values()] })
|
|
323
328
|
if (files instanceof Error) {
|
package/src/login.e2e.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ test('/login API key stores an OpenCode credential and makes its provider availa
|
|
|
40
40
|
Connect OpenAI
|
|
41
41
|
Connected OpenAI. Use /model to select a model.
|
|
42
42
|
**Set Model Preference**
|
|
43
|
-
**Current
|
|
43
|
+
**Current (OpenCode default):** \`deterministic-provider/deterministic-v2\`
|
|
44
44
|
Select a provider:"
|
|
45
45
|
`)
|
|
46
46
|
expect(models.select.options.some((option) => option.value === 'openai')).toBe(true)
|
|
@@ -75,7 +75,7 @@ test('/login OAuth code uses native attempts and can activate a stored credentia
|
|
|
75
75
|
Connect OpenAI
|
|
76
76
|
Connected OpenAI. Use /model to select a model.
|
|
77
77
|
**Set Model Preference**
|
|
78
|
-
**Current
|
|
78
|
+
**Current (OpenCode default):** \`deterministic-provider/deterministic-v2\`
|
|
79
79
|
Select a provider:
|
|
80
80
|
http://127.0.0.1/authorize
|
|
81
81
|
Enter the test authorization code.
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { createLogger } from './logger.ts'
|
|
|
20
20
|
import { installPluginShim, watchOpencode, type OpencodeEndpoint } from './opencode-server.ts'
|
|
21
21
|
import { countUserProjects } from './project.ts'
|
|
22
22
|
import { createScheduler, systemClock, type Clock } from './scheduler.ts'
|
|
23
|
-
import { createEventRecorder } from './session-events.ts'
|
|
23
|
+
import { createEventRecorder, syncSessionTranscript } from './session-events.ts'
|
|
24
24
|
import { createPluginWait, refreshCliContext } from './sessions.ts'
|
|
25
25
|
import { createInteractionRegistry, registerSlashCommands } from './slash-commands.ts'
|
|
26
26
|
import { createSleepLock } from './sleeps.ts'
|
|
@@ -65,6 +65,9 @@ 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
|
|
69
|
+
// Built-in skill ids denied in new sessions (builtin-skills.ts).
|
|
70
|
+
disabledSkills?: readonly string[]
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export type BotHandle = Bot & {
|
|
@@ -138,7 +141,12 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
138
141
|
})
|
|
139
142
|
cleanup.defer(() => effects.stop())
|
|
140
143
|
const voiceCalls = createVoiceCalls({ opencodeConfigDir: options.opencodeConfigDir })
|
|
141
|
-
const
|
|
144
|
+
const onTranscriptDue = (sessionId: string) => {
|
|
145
|
+
void oc({ opencode }, 'sync transcript', (client) => syncSessionTranscript({ client, dataDir: options.dataDir, sessionId })).then((result) => {
|
|
146
|
+
if (result instanceof Error) logger.warn(`transcript of ${sessionId} not synced`, result)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
const eventLoop = createEventLoop({ store, db, discord, effects, recorder, analytics: options.analytics, onRunEnded: voiceCalls.runEnded, onTranscriptDue })
|
|
142
150
|
const loaded = await eventLoop.load()
|
|
143
151
|
if (loaded instanceof Error) return loaded
|
|
144
152
|
// Before the service is used, so a service started by ensure() loads it at once.
|
|
@@ -175,6 +183,8 @@ export async function startBot(options: StartBotOptions): Promise<Error | BotHan
|
|
|
175
183
|
transcriptionBaseUrls: options.transcriptionBaseUrls ?? {},
|
|
176
184
|
realtimeBaseUrls: options.realtimeBaseUrls ?? {},
|
|
177
185
|
autoWorktrees: options.autoWorktrees ?? false,
|
|
186
|
+
allowAllUsers: options.allowAllUsers ?? false,
|
|
187
|
+
disabledSkills: options.disabledSkills ?? [],
|
|
178
188
|
features: { withSleepLock: createSleepLock(), waitForPlugin: createPluginWait({ opencode }), agentUi, voiceCalls },
|
|
179
189
|
}
|
|
180
190
|
const scheduler = createScheduler({
|
|
@@ -18,7 +18,7 @@ test('supported binary wins over OpenCode 1, only OpenCode 1 is an error, nothin
|
|
|
18
18
|
|
|
19
19
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v2, v1] })).toBe(v2)
|
|
20
20
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1, v2] })).toBe(v2)
|
|
21
|
-
expect(message(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1] }))).toMatchInlineSnapshot(`"Kimaki now requires OpenCode 2, but <dir>/opencode is OpenCode 1.15.5. Install OpenCode 2 with: curl -fsSL https://opencode.ai/v2/install | bash (or: npm i -g @opencode/cli). Then run kimaki again"`)
|
|
21
|
+
expect(message(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2', v1] }))).toMatchInlineSnapshot(`"Kimaki now requires OpenCode 2, but <dir>/opencode is OpenCode 1.15.5. Install OpenCode 2 with: curl -fsSL https://opencode.ai/v2/install | bash (or: npm i -g @opencode/cli). Then run kimaki again. To keep using OpenCode 1, run the older Kimaki instead: npx kimaki@0"`)
|
|
22
22
|
expect(await findOpencodeBinary({ candidates: ['kimaki-missing-opencode2'] })).toBe(null)
|
|
23
23
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
24
24
|
})
|
package/src/opencode-server.ts
CHANGED
|
@@ -147,14 +147,16 @@ export function opencodeDbFile(): string {
|
|
|
147
147
|
// through its index module. Always dist, also when the bot runs from src: OpenCode
|
|
148
148
|
// reloads the plugin when its files change, so a src target would reload half
|
|
149
149
|
// edited code on every save. dist only changes on `pnpm build`.
|
|
150
|
-
// `kimaki` acts only on marked sessions
|
|
151
|
-
// `kimaki-
|
|
150
|
+
// `kimaki` acts only on marked sessions (except the session ID line);
|
|
151
|
+
// `kimaki-anthropic` (provider auth) applies to every session.
|
|
152
152
|
const PLUGIN_SHIMS = [
|
|
153
153
|
{ name: 'kimaki', directory: '' },
|
|
154
154
|
{ name: 'kimaki-anthropic', directory: 'anthropic' },
|
|
155
|
-
{ name: 'kimaki-session-id', directory: 'session-id' },
|
|
156
155
|
] as const
|
|
157
156
|
|
|
157
|
+
// Shims of older Kimaki versions, removed on bot start. kimaki-session-id moved into the kimaki plugin.
|
|
158
|
+
const LEGACY_SHIMS = ['kimaki-session-id']
|
|
159
|
+
|
|
158
160
|
export function kimakiPluginEntry(directory: string = ''): string {
|
|
159
161
|
// This module is <package>/src/opencode-server.ts or <package>/dist/opencode-server.js.
|
|
160
162
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
@@ -233,26 +235,27 @@ async function installShim({ configDir, name, entry }: { configDir: string; name
|
|
|
233
235
|
// A shim whose plugin the global config already lists would load one plugin ID
|
|
234
236
|
// twice. OpenCode keeps the first and reports the other as a failed plugin
|
|
235
237
|
// ("Duplicate plugin ID", plugin/supervisor.ts), so the shim is removed instead.
|
|
236
|
-
async function removeShim({ configDir, name }: { configDir: string; name: string }) {
|
|
238
|
+
async function removeShim({ configDir, name, why }: { configDir: string; name: string; why: string }) {
|
|
237
239
|
const directory = path.join(configDir, 'plugins', name)
|
|
238
240
|
if (!fs.existsSync(directory)) return { file: directory, written: false }
|
|
239
241
|
const removed = await fs.promises
|
|
240
242
|
.rm(directory, { recursive: true, force: true })
|
|
241
243
|
.catch((cause) => new ConfigError({ reason: `Cannot remove the Kimaki OpenCode plugin shim ${directory}. Check permissions of ${configDir}`, cause }))
|
|
242
244
|
if (removed instanceof Error) return removed
|
|
243
|
-
logger.log(`removed OpenCode plugin shim ${directory}:
|
|
245
|
+
logger.log(`removed OpenCode plugin shim ${directory}: ${why}`)
|
|
244
246
|
return { file: directory, written: true }
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
export async function installPluginShim({ configDir }: { configDir: string }): Promise<ConfigError | { written: boolean }> {
|
|
248
250
|
const targets = await configuredPluginTargets({ configDir })
|
|
249
|
-
const results = await Promise.all(
|
|
250
|
-
PLUGIN_SHIMS.map(async (shim) => {
|
|
251
|
+
const results = await Promise.all([
|
|
252
|
+
...PLUGIN_SHIMS.map(async (shim) => {
|
|
251
253
|
const configured = await Promise.all(targets.map((target) => isKimakiPluginDirectory({ target, directory: shim.directory })))
|
|
252
|
-
if (configured.includes(true)) return removeShim({ configDir, name: shim.name })
|
|
254
|
+
if (configured.includes(true)) return removeShim({ configDir, name: shim.name, why: 'opencode.json already loads this plugin' })
|
|
253
255
|
return installShim({ configDir, name: shim.name, entry: kimakiPluginEntry(shim.directory) })
|
|
254
256
|
}),
|
|
255
|
-
|
|
257
|
+
...LEGACY_SHIMS.map((name) => removeShim({ configDir, name, why: 'replaced by the kimaki plugin' })),
|
|
258
|
+
])
|
|
256
259
|
const failed = results.find((result) => result instanceof Error)
|
|
257
260
|
if (failed instanceof Error) return failed
|
|
258
261
|
return { written: results.some((result) => !(result instanceof Error) && result.written) }
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import { Plugin } from '@opencode/plugin'
|
|
1
|
+
import { Plugin, Skill } from '@opencode/plugin'
|
|
2
|
+
import { Schema } from 'effect'
|
|
2
3
|
import { execFile } from 'node:child_process'
|
|
3
4
|
import { promisify } from 'node:util'
|
|
4
5
|
import path from 'node:path'
|
|
5
6
|
|
|
6
7
|
import { autostartBot } from '../autostart.ts'
|
|
8
|
+
import { readBuiltinSkills } from '../builtin-skills.ts'
|
|
7
9
|
import { fileEditTool, recordToolEdits } from '../file-edit-log.ts'
|
|
8
10
|
import { turnContext } from '../system-prompt.ts'
|
|
9
11
|
|
|
10
12
|
const exec = promisify(execFile)
|
|
11
13
|
|
|
14
|
+
const decodeSkill = Schema.decodeUnknownSync(Skill.Info)
|
|
15
|
+
|
|
12
16
|
const BRANCH_PREFIX = '[current git branch is '
|
|
13
17
|
|
|
14
18
|
export default Plugin.define({
|
|
15
19
|
id: 'kimaki',
|
|
16
20
|
async setup(ctx) {
|
|
17
21
|
autostartBot()
|
|
22
|
+
// Skills are per location, not per session, so OpenCode lists them in every
|
|
23
|
+
// session, TUI included. Descriptions say they are for Kimaki sessions only;
|
|
24
|
+
// the bot denies disabled ones per session (--disable-skills). A failed read
|
|
25
|
+
// only loses the skills.
|
|
26
|
+
const skills = await readBuiltinSkills()
|
|
27
|
+
if (!(skills instanceof Error)) {
|
|
28
|
+
await ctx.skill.transform((editor) => {
|
|
29
|
+
for (const skill of skills) {
|
|
30
|
+
editor.add(decodeSkill(skill))
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
18
34
|
async function marker(sessionID: string) {
|
|
19
35
|
const session = await ctx.session.get({ sessionID }).catch(() => null)
|
|
20
36
|
const value: unknown = session?.metadata?.['kimaki']
|
|
@@ -43,6 +59,9 @@ export default Plugin.define({
|
|
|
43
59
|
await ctx.session.synthetic({ sessionID: event.sessionID, text, delivery: event.delivery, resume: false }).catch(() => undefined)
|
|
44
60
|
})
|
|
45
61
|
await ctx.session.hook('context', async (event) => {
|
|
62
|
+
// The only prompt change for every session, TUI included. The ID never
|
|
63
|
+
// changes in a session, so the cached prompt prefix stays stable.
|
|
64
|
+
event.system.push({ type: 'text', text: `OpenCode session ID: ${event.sessionID}` })
|
|
46
65
|
if (!(await marker(event.sessionID))) return
|
|
47
66
|
// The <discord-user> context of each prompt, built from its metadata, so the
|
|
48
67
|
// stored text stays clean for the TUI and other clients. Same metadata, same
|
package/src/plugin.e2e.test.ts
CHANGED
|
@@ -52,8 +52,13 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
|
|
|
52
52
|
}
|
|
53
53
|
const kimakiStatus = () => pluginStatus('kimaki')
|
|
54
54
|
expect(await kimakiStatus()).toBe('missing')
|
|
55
|
+
// Shim of Kimaki 1.0.0; the session ID line now lives in the kimaki plugin.
|
|
56
|
+
const legacyShim = path.join(server.configDir, 'plugins', 'kimaki-session-id')
|
|
57
|
+
fs.mkdirSync(legacyShim, { recursive: true })
|
|
58
|
+
fs.writeFileSync(path.join(legacyShim, 'index.js'), 'export default undefined\n')
|
|
55
59
|
|
|
56
60
|
bot = await startTestBot({ dataDir, twin, server })
|
|
61
|
+
expect(fs.existsSync(legacyShim)).toBe(false)
|
|
57
62
|
expect(fs.readFileSync(shim, 'utf8').replace(/file:\/\/\S+\/dist\//, 'file:///<kimaki>/dist/')).toMatchInlineSnapshot(`
|
|
58
63
|
"// Generated by Kimaki on bot start. Loads the plugin of the running Kimaki install.
|
|
59
64
|
export { default } from "file:///<kimaki>/dist/plugin/index.js"
|
|
@@ -65,7 +70,7 @@ test('bot start writes the plugin shim, OpenCode loads it, a restart does not re
|
|
|
65
70
|
await waitForFooter({ discord: twin.discord, threadId: thread.id })
|
|
66
71
|
expect(await kimakiStatus()).toBe('active')
|
|
67
72
|
expect(await pluginStatus('kimaki-anthropic')).toBe('active')
|
|
68
|
-
expect(await pluginStatus('kimaki-session-id')).toBe('
|
|
73
|
+
expect(await pluginStatus('kimaki-session-id')).toBe('missing')
|
|
69
74
|
expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
70
75
|
"--- from: user (tommy)
|
|
71
76
|
plugin shim turn
|
package/src/prompt.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { allMessages } from './session-events.ts'
|
|
|
44
44
|
import { ensureSessionMarker, forkBtw, startSession, switchAgent, threadProject } from './sessions.ts'
|
|
45
45
|
import { cancelSleep } from './sleeps.ts'
|
|
46
46
|
import type { ScheduledRun } from './system-prompt.ts'
|
|
47
|
-
import {
|
|
47
|
+
import { attachmentsBlock, type PromptAttachment, type TurnContext } from './system-prompt.ts'
|
|
48
48
|
import { isBusy, type UploadFile } from './thread-reducer.ts'
|
|
49
49
|
import { resolveWorkingDirectory } from './worktrees.ts'
|
|
50
50
|
|
|
@@ -88,10 +88,10 @@ export async function prompt(
|
|
|
88
88
|
echo?: string
|
|
89
89
|
},
|
|
90
90
|
): Promise<OpenCodeUnavailableError | OpenCodeError | void> {
|
|
91
|
-
const { attached,
|
|
91
|
+
const { attached, attachments } = await splitPromptFiles(files)
|
|
92
92
|
// The text stays as the user wrote it. The plugin renders the <discord-user>
|
|
93
93
|
// context from this metadata into the model request only (turnContext()).
|
|
94
|
-
const discord: TurnContext = { userId: author.id, username: author.username, messageId, threadId, threadName,
|
|
94
|
+
const discord: TurnContext = { userId: author.id, username: author.username, messageId, threadId, threadName, attachments }
|
|
95
95
|
const result = await oc(bot, 'session.prompt', (client) =>
|
|
96
96
|
client.session.prompt({
|
|
97
97
|
sessionID: sessionId,
|
|
@@ -113,19 +113,19 @@ export async function prompt(
|
|
|
113
113
|
const OPENCODE_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
114
114
|
const INLINE_MEDIA_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf'])
|
|
115
115
|
|
|
116
|
+
// `attachments` keeps every file in the user's order, so "the second image" resolves.
|
|
116
117
|
async function splitPromptFiles(files: readonly PromptFile[]) {
|
|
117
118
|
const attached: Array<{ uri: string; name: string }> = []
|
|
118
|
-
const
|
|
119
|
+
const attachments: PromptAttachment[] = []
|
|
119
120
|
for (const file of files) {
|
|
120
121
|
const local = promptFilePath(file)
|
|
121
122
|
const stat = await fs.promises.stat(local).catch(() => null)
|
|
122
123
|
const tooBig = stat !== null && stat.size > OPENCODE_MAX_ATTACHMENT_BYTES
|
|
123
|
-
const inline = INLINE_MEDIA_EXTENSIONS.has(path.extname(file.name).toLowerCase())
|
|
124
124
|
if (!tooBig) attached.push({ uri: file.uri, name: file.name })
|
|
125
|
-
// Text files
|
|
126
|
-
|
|
125
|
+
// Text files count as not inline: the extension cannot tell text from binary.
|
|
126
|
+
attachments.push({ name: file.name, path: local, inline: !tooBig && INLINE_MEDIA_EXTENSIONS.has(path.extname(file.name).toLowerCase()) })
|
|
127
127
|
}
|
|
128
|
-
return { attached,
|
|
128
|
+
return { attached, attachments }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Questions and permissions of the thread (root and children) that wait for the user.
|
|
@@ -299,8 +299,8 @@ async function command(
|
|
|
299
299
|
const interrupted = await interrupt(bot, input.sessionId)
|
|
300
300
|
if (interrupted instanceof Error) return interrupted
|
|
301
301
|
}
|
|
302
|
-
const { attached,
|
|
303
|
-
const block =
|
|
302
|
+
const { attached, attachments } = await splitPromptFiles(input.files)
|
|
303
|
+
const block = attachmentsBlock(attachments)
|
|
304
304
|
bot.eventLoop.dispatch(input.threadId, { type: 'kimaki.command', text: typed, username: input.author.username })
|
|
305
305
|
const result = await oc(bot, 'session.command', (client) =>
|
|
306
306
|
client.session.command({
|
package/src/session-events.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// kimaki session events <sessionId|threadId> print the JSONL
|
|
11
11
|
// kimaki session read <sessionId|threadId> print the messages from OpenCode
|
|
12
|
+
// kimaki session sync write <dataDir>/transcripts/<sessionId>.md for rg
|
|
12
13
|
|
|
14
|
+
import crypto from 'node:crypto'
|
|
13
15
|
import fs from 'node:fs'
|
|
14
16
|
import path from 'node:path'
|
|
15
17
|
import type { OpenCodeClient } from '@opencode/client'
|
|
@@ -106,6 +108,7 @@ export async function resolveSession({
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
|
|
111
|
+
type Session = Awaited<ReturnType<OpenCodeClient['session']['get']>>
|
|
109
112
|
|
|
110
113
|
function renderMessage(message: Message, options: { thinking?: boolean; verbose?: boolean }): string {
|
|
111
114
|
const time = new Date(message.time.created).toISOString()
|
|
@@ -146,10 +149,90 @@ export async function readSessionMarkdown({
|
|
|
146
149
|
if (session instanceof Error) return session
|
|
147
150
|
const messages = await allMessages({ client, sessionId })
|
|
148
151
|
if (messages instanceof Error) return messages
|
|
152
|
+
return renderSession({ session, messages, thinking, verbose })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderSession({ session, messages, thinking, verbose }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean }): string {
|
|
149
156
|
const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}`
|
|
150
157
|
return [title, ...messages.map((message) => renderMessage(message, { thinking, verbose }))].join('\n\n')
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
// Transcripts: <dataDir>/transcripts/<sessionId>.md, the `session read` markdown
|
|
161
|
+
// (no thinking, no tool outputs) so agents can search every session with rg.
|
|
162
|
+
// The file mtime is the session's time.updated: a file at least that new is current.
|
|
163
|
+
export function transcriptsDir({ dataDir }: { dataDir: string }): string {
|
|
164
|
+
return path.join(dataDir, 'transcripts')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function writeTranscript({ client, dataDir, session }: { client: OpenCodeClient; dataDir: string; session: Pick<Session, 'id' | 'title' | 'location'> & { time: Pick<Session['time'], 'updated'> } }): Promise<OpenCodeError | ConfigError | void> {
|
|
168
|
+
const messages = await allMessages({ client, sessionId: session.id })
|
|
169
|
+
if (messages instanceof Error) return messages
|
|
170
|
+
const dir = transcriptsDir({ dataDir })
|
|
171
|
+
const file = path.join(dir, `${session.id}.md`)
|
|
172
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
173
|
+
const written = await (async () => {
|
|
174
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 })
|
|
175
|
+
await fs.promises.writeFile(temp, `${renderSession({ session, messages })}\n`, { mode: 0o600 })
|
|
176
|
+
await fs.promises.utimes(temp, session.time.updated / 1000, session.time.updated / 1000)
|
|
177
|
+
await fs.promises.rename(temp, file)
|
|
178
|
+
})().catch((cause) => new ConfigError({ reason: `Cannot write ${file}`, cause }))
|
|
179
|
+
if (written instanceof Error) await fs.promises.rm(temp, { force: true }).catch((error) => logger.warn(`cannot remove ${temp}`, error))
|
|
180
|
+
return written
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The bot calls this when a run ends, so transcripts of new sessions appear on their own.
|
|
184
|
+
export async function syncSessionTranscript({ client, dataDir, sessionId }: { client: OpenCodeClient; dataDir: string; sessionId: string }): Promise<OpenCodeError | ConfigError | void> {
|
|
185
|
+
const session = await client.session.get({ sessionID: sessionId }).catch((cause) => new OpenCodeError({ operation: 'session.get', cause }))
|
|
186
|
+
if (session instanceof Error) return session
|
|
187
|
+
return writeTranscript({ client, dataDir, session })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type SyncResult = { written: number; skipped: number; failed: number }
|
|
191
|
+
|
|
192
|
+
// Sessions updated in the last `days` days (0 = all time) of every project.
|
|
193
|
+
// Skips sessions whose transcript is current, unless `rebuild`.
|
|
194
|
+
export async function syncTranscripts({
|
|
195
|
+
client,
|
|
196
|
+
dataDir,
|
|
197
|
+
days,
|
|
198
|
+
rebuild,
|
|
199
|
+
}: {
|
|
200
|
+
client: OpenCodeClient
|
|
201
|
+
dataDir: string
|
|
202
|
+
days: number
|
|
203
|
+
rebuild: boolean
|
|
204
|
+
}): Promise<OpenCodeError | SyncResult> {
|
|
205
|
+
const [sessions, active] = await Promise.all([allSessions({ client }), client.session.active().catch((cause) => new OpenCodeError({ operation: 'session.active', cause }))])
|
|
206
|
+
if (sessions instanceof Error) return sessions
|
|
207
|
+
if (active instanceof Error) return active
|
|
208
|
+
const cutoff = days > 0 ? Date.now() - days * 86_400_000 : 0
|
|
209
|
+
const recent = sessions.filter((session) => session.time.updated >= cutoff)
|
|
210
|
+
const result: SyncResult = { written: 0, skipped: 0, failed: 0 }
|
|
211
|
+
const next = { index: 0 }
|
|
212
|
+
const worker = async () => {
|
|
213
|
+
while (next.index < recent.length) {
|
|
214
|
+
const session = recent[next.index++]!
|
|
215
|
+
const file = path.join(transcriptsDir({ dataDir }), `${session.id}.md`)
|
|
216
|
+
const stat = await fs.promises.stat(file).catch(() => null)
|
|
217
|
+
// time.updated may not move when a run ends: the live hook writes busy sessions when they stop.
|
|
218
|
+
if (session.id in active || (!rebuild && stat && stat.mtimeMs >= session.time.updated - 1)) {
|
|
219
|
+
result.skipped++
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const written = await writeTranscript({ client, dataDir, session })
|
|
223
|
+
if (written instanceof Error) {
|
|
224
|
+
result.failed++
|
|
225
|
+
logger.warn(`transcript of ${session.id} failed`, written)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
result.written++
|
|
229
|
+
logger.log(`transcript ${result.written + result.skipped + result.failed}/${recent.length}: ${session.id}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await Promise.all(Array.from({ length: Math.min(8, recent.length) }, worker))
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
|
|
153
236
|
// Cursor pagination of message.list / session.list: every page, in order.
|
|
154
237
|
export async function collectPages<T>({
|
|
155
238
|
operation,
|
package/src/sessions.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { prompt, runInSession } from './prompt.ts'
|
|
|
33
33
|
import { formatEcho } from './queue.ts'
|
|
34
34
|
import type { Route } from './routes.ts'
|
|
35
35
|
import * as schema from './schema.ts'
|
|
36
|
+
import { disabledSkillRules } from './builtin-skills.ts'
|
|
36
37
|
import { baseInstructions, INSTRUCTION_KEY, type ScheduledRun } from './system-prompt.ts'
|
|
37
38
|
import { createWorktree, resolveWorkingDirectory } from './worktrees.ts'
|
|
38
39
|
|
|
@@ -423,7 +424,7 @@ export async function startSession(
|
|
|
423
424
|
location: { directory },
|
|
424
425
|
...(model && { model }),
|
|
425
426
|
...(agent && { agent }),
|
|
426
|
-
permissions: [NO_QUESTION_TOOL, ...(permissions ?? [])],
|
|
427
|
+
permissions: [NO_QUESTION_TOOL, ...disabledSkillRules(bot.disabledSkills), ...(permissions ?? [])],
|
|
427
428
|
metadata: { kimaki },
|
|
428
429
|
}),
|
|
429
430
|
)
|
|
@@ -533,7 +534,7 @@ async function adoptSession(
|
|
|
533
534
|
const kimaki = { ...marker, source: scheduledTask ? 'task' : 'discord', ...cliContext(bot), threadId: thread.id, channelId: channel.id }
|
|
534
535
|
// A session with its own question rule (a fork, or --permission) keeps it.
|
|
535
536
|
const rules = session.permissions ?? []
|
|
536
|
-
const permissions = rules.some((rule) => rule.action === 'question') ? rules : [NO_QUESTION_TOOL, ...rules]
|
|
537
|
+
const permissions = rules.some((rule) => rule.action === 'question') ? rules : [NO_QUESTION_TOOL, ...disabledSkillRules(bot.disabledSkills), ...rules]
|
|
537
538
|
const marked = await oc(bot, 'session.update', (client) =>
|
|
538
539
|
client.session.update({ sessionID: sessionId, metadata: { ...session.metadata, kimaki }, permissions }),
|
|
539
540
|
)
|
package/src/slash-commands.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// Discord allows 100 commands per guild; skills are cut first.
|
|
34
34
|
|
|
35
|
+
import { isBuiltinSkill } from './builtin-skills.ts'
|
|
35
36
|
import {
|
|
36
37
|
Events,
|
|
37
38
|
MessageFlags,
|
|
@@ -175,7 +176,7 @@ async function readCatalog(bot: Pick<Bot, 'opencode'>, directory: string): Promi
|
|
|
175
176
|
if (agents instanceof Error) return agents
|
|
176
177
|
if (commands instanceof Error) return commands
|
|
177
178
|
if (skills instanceof Error) return skills
|
|
178
|
-
return { agents: agents.data, commands: commands.data, skills: skills.data }
|
|
179
|
+
return { agents: agents.data, commands: commands.data, skills: skills.data.filter((skill) => !isBuiltinSkill(skill.id)) }
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
// Autocomplete keeps the whole project catalog reachable beyond Discord's shortcut limit.
|
|
@@ -187,7 +188,7 @@ function catalogRoutes(): InteractionRoutes {
|
|
|
187
188
|
if (kind === 'skill') {
|
|
188
189
|
const skills = await oc(bot, 'skill.list', (client) => client.skill.list({ location }))
|
|
189
190
|
if (skills instanceof Error) return skills
|
|
190
|
-
return skills.data.map((skill) => ({ name: skill.id, description: skill.description }))
|
|
191
|
+
return skills.data.filter((skill) => !isBuiltinSkill(skill.id)).map((skill) => ({ name: skill.id, description: skill.description }))
|
|
191
192
|
}
|
|
192
193
|
const list = await oc(bot, 'command.list', (client) => client.command.list({ location }))
|
|
193
194
|
if (list instanceof Error) return list
|
|
@@ -460,7 +461,7 @@ export function registerSlashCommands(
|
|
|
460
461
|
if (project instanceof Error) return logger.warn(project)
|
|
461
462
|
if (!project) return
|
|
462
463
|
const guild = interaction.guild ?? (await discord.guilds.fetch(interaction.guildId).catch(() => null))
|
|
463
|
-
if (!guild || !(await canUseKimaki({ guild, userId: interaction.user.id }))) {
|
|
464
|
+
if (!guild || !(await canUseKimaki(bot, { guild, userId: interaction.user.id }))) {
|
|
464
465
|
if (interaction.isAutocomplete()) return respondChoices(interaction, [])
|
|
465
466
|
if (interaction.isRepliable()) {
|
|
466
467
|
await interaction.reply({ content: 'You do not have permission to use Kimaki', flags: MessageFlags.Ephemeral })
|