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/autostart.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// on every start; the Kimaki OpenCode plugin opens it when no bot answers on
|
|
3
3
|
// the lock port:
|
|
4
4
|
//
|
|
5
|
-
// kimaki start ──writes──▶ ~/.kimaki/autostart.command (exec node <pkg>/dist/cli.js ...)
|
|
5
|
+
// kimaki start ──writes──▶ ~/.kimaki/autostart.command (exec <bot node> <pkg>/dist/cli.js ...)
|
|
6
6
|
// ▲
|
|
7
7
|
// OpenCode loads plugin ──no /health on 29988──▶ open -g autostart.command
|
|
8
8
|
// (default terminal app, in background)
|
|
@@ -37,13 +37,25 @@ export async function writeAutostartScript({ dataDir, args }: { dataDir: string;
|
|
|
37
37
|
const file = path.join(dataDir, SCRIPT_NAME)
|
|
38
38
|
const cli = distCliEntry()
|
|
39
39
|
if (!fs.existsSync(cli)) return fs.promises.rm(file, { force: true }).catch((cause: Error) => cause)
|
|
40
|
-
const command = [
|
|
40
|
+
const command = [cli, '--if-not-running', '--data-dir', dataDir, ...args].map(quote).join(' ')
|
|
41
|
+
// OpenCode is a compiled Bun binary with no Node inside, so reuse the Node that ran
|
|
42
|
+
// this bot. Fall back to PATH when a version manager removed that Node.
|
|
41
43
|
// The bot inherits the OpenCode (or Terminal) env: drop overrides that would change its port, token or service.
|
|
42
44
|
const script = dedent`
|
|
43
45
|
#!/bin/sh
|
|
44
46
|
# Generated by Kimaki on bot start. The Kimaki OpenCode plugin opens it when no bot runs.
|
|
45
47
|
unset KIMAKI_LOCK_PORT KIMAKI_BOT_TOKEN KIMAKI_OPENCODE_SERVICE_FILE KIMAKI_DATA_DIR KIMAKI_SUPERVISED
|
|
46
|
-
|
|
48
|
+
node=${quote(process.execPath)}
|
|
49
|
+
[ -x "$node" ] || node=node
|
|
50
|
+
echo 'Kimaki autostart'
|
|
51
|
+
echo 'OpenCode started and no Kimaki bot answered on port ${DEFAULT_LOCK_PORT}, so the Kimaki'
|
|
52
|
+
echo 'OpenCode plugin opened this window to start the Discord bot.'
|
|
53
|
+
echo 'The bot lets you talk to your OpenCode sessions from Discord.'
|
|
54
|
+
echo 'Close this window or press Ctrl+C to stop the bot.'
|
|
55
|
+
echo "Node: $node"
|
|
56
|
+
echo "Script: $0"
|
|
57
|
+
echo
|
|
58
|
+
exec "$node" ${command}
|
|
47
59
|
` + '\n'
|
|
48
60
|
return fs.promises.writeFile(file, script, { mode: 0o755 }).then(() => fs.promises.chmod(file, 0o755))
|
|
49
61
|
.catch((cause: Error) => cause)
|
package/src/bot.ts
CHANGED
|
@@ -65,6 +65,10 @@ export type Bot = {
|
|
|
65
65
|
realtimeBaseUrls: RealtimeBaseUrls
|
|
66
66
|
// Default for channels without a channel_worktrees row.
|
|
67
67
|
autoWorktrees: boolean
|
|
68
|
+
// --allow-all-users: every guild member can use the bot (canUseKimaki). Admin-only commands stay admin-only.
|
|
69
|
+
allowAllUsers: boolean
|
|
70
|
+
// Built-in skill ids (builtin-skills.ts) denied through the permissions of new sessions.
|
|
71
|
+
disabledSkills: readonly string[]
|
|
68
72
|
features: BotFeatures
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { BUILTIN_SKILL_IDS, disabledSkillRules, parseDisabledSkills, readBuiltinSkills } from './builtin-skills.ts'
|
|
4
|
+
|
|
5
|
+
test('every built-in skill file parses', async () => {
|
|
6
|
+
const skills = await readBuiltinSkills()
|
|
7
|
+
if (skills instanceof Error) throw skills
|
|
8
|
+
expect(skills.map((skill) => skill.id)).toEqual([...BUILTIN_SKILL_IDS])
|
|
9
|
+
for (const skill of skills) {
|
|
10
|
+
expect(skill.description, skill.id).toContain('Only for Kimaki Discord sessions')
|
|
11
|
+
expect(skill.content.startsWith('# '), skill.id).toBe(true)
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('--disable-skills accepts all, a list, and rejects unknown ids', () => {
|
|
16
|
+
expect(parseDisabledSkills(undefined)).toEqual([])
|
|
17
|
+
expect(parseDisabledSkills('kimaki-tunnel, kimaki-debug')).toEqual(['kimaki-tunnel', 'kimaki-debug'])
|
|
18
|
+
expect(parseDisabledSkills('all')).toEqual([...BUILTIN_SKILL_IDS])
|
|
19
|
+
expect(parseDisabledSkills('tunnel')).toBeInstanceOf(Error)
|
|
20
|
+
expect(disabledSkillRules(['kimaki-tunnel'])).toMatchInlineSnapshot(`
|
|
21
|
+
[
|
|
22
|
+
{
|
|
23
|
+
"action": "skill",
|
|
24
|
+
"effect": "deny",
|
|
25
|
+
"resource": "kimaki-tunnel",
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
`)
|
|
29
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Opt-out skills Kimaki ships in skills/<id>/SKILL.md (debug, tunnel). They are
|
|
2
|
+
// not in the system prompt, which keeps the rest of the CLI reference.
|
|
3
|
+
//
|
|
4
|
+
// plugin (OpenCode process) ──▶ readBuiltinSkills ──▶ ctx.skill.transform(add)
|
|
5
|
+
// bot ──▶ --disable-skills ──▶ session permissions `skill:<id>` deny
|
|
6
|
+
//
|
|
7
|
+
// Plugin files must not pull in discord.js or SQLite: keep this module plain fs.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
import { ConfigError, FilesystemError } from './errors.ts'
|
|
14
|
+
|
|
15
|
+
export const BUILTIN_SKILL_IDS = ['kimaki-debug', 'kimaki-tunnel'] as const
|
|
16
|
+
|
|
17
|
+
export type BuiltinSkillId = (typeof BUILTIN_SKILL_IDS)[number]
|
|
18
|
+
|
|
19
|
+
export function isBuiltinSkill(id: string): boolean {
|
|
20
|
+
return (BUILTIN_SKILL_IDS as readonly string[]).includes(id)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/ and dist/ both sit next to skills/.
|
|
24
|
+
const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills')
|
|
25
|
+
|
|
26
|
+
export type BuiltinSkill = { id: string; name: string; description: string; path: string; content: string }
|
|
27
|
+
|
|
28
|
+
function frontmatterValue(frontmatter: string, key: string): string | undefined {
|
|
29
|
+
return new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(frontmatter)?.[1]?.trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readBuiltinSkills(): Promise<FilesystemError | BuiltinSkill[]> {
|
|
33
|
+
const skills: BuiltinSkill[] = []
|
|
34
|
+
for (const id of BUILTIN_SKILL_IDS) {
|
|
35
|
+
const file = path.join(SKILLS_DIR, id, 'SKILL.md')
|
|
36
|
+
const text = await fs.promises.readFile(file, 'utf8').catch((cause) => new FilesystemError({ operation: `read ${file}`, cause }))
|
|
37
|
+
if (text instanceof Error) return text
|
|
38
|
+
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text)
|
|
39
|
+
const description = match?.[1] ? frontmatterValue(match[1], 'description') : undefined
|
|
40
|
+
if (!match || !description) return new FilesystemError({ operation: `parse ${file}: missing frontmatter description` })
|
|
41
|
+
// Virtual path: OpenCode has no file to scan for these (same as its /builtin skills).
|
|
42
|
+
skills.push({ id, name: id, description, path: `/kimaki/${id}.md`, content: match[2]!.trim() })
|
|
43
|
+
}
|
|
44
|
+
return skills
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `all` or a comma separated list of built-in skill ids.
|
|
48
|
+
export function parseDisabledSkills(value: string | undefined): ConfigError | BuiltinSkillId[] {
|
|
49
|
+
const items = (value ?? '').split(',').map((item) => item.trim()).filter(Boolean)
|
|
50
|
+
if (items.includes('all')) return [...BUILTIN_SKILL_IDS]
|
|
51
|
+
const unknown = items.filter((item) => !isBuiltinSkill(item))
|
|
52
|
+
if (unknown.length > 0) return new ConfigError({ reason: `Unknown built-in skill ${unknown.join(', ')}. Choose from: all, ${BUILTIN_SKILL_IDS.join(', ')}` })
|
|
53
|
+
return items as BuiltinSkillId[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// OpenCode hides a skill from the model and the skill tool when its permission is denied.
|
|
57
|
+
export function disabledSkillRules(disabled: readonly string[]) {
|
|
58
|
+
return disabled.map((id) => ({ action: 'skill', resource: id, effect: 'deny' }) as const)
|
|
59
|
+
}
|
package/src/cli/bot.ts
CHANGED
|
@@ -11,6 +11,7 @@ import dedent from 'string-dedent'
|
|
|
11
11
|
import type { Goke } from 'goke'
|
|
12
12
|
|
|
13
13
|
import { distCliEntry, writeAutostartScript } from '../autostart.ts'
|
|
14
|
+
import { parseDisabledSkills } from '../builtin-skills.ts'
|
|
14
15
|
import { callBot, DEFAULT_LOCK_PORT, RESTART_EXIT_CODE, startLockServer } from '../lock-server.ts'
|
|
15
16
|
import { parseDuration } from '../duration.ts'
|
|
16
17
|
import { createLogger, setLogFile } from '../logger.ts'
|
|
@@ -87,7 +88,9 @@ export function registerStartCommand(cli: Goke) {
|
|
|
87
88
|
.option('--machine-name <name>', 'Name in this machine\'s category "Kimaki <name>" (default: hostname)')
|
|
88
89
|
.option('--restart-onboarding', 'Choose credentials again')
|
|
89
90
|
.option('--worktrees', 'Use a fresh Git worktree for new sessions unless the channel overrides it')
|
|
91
|
+
.option('--allow-all-users', 'Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)')
|
|
90
92
|
.option('--voice <name>', 'Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it')
|
|
93
|
+
.option('--disable-skills <list>', 'Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)')
|
|
91
94
|
.option('--no-analytics', 'Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)')
|
|
92
95
|
.option('--if-not-running', 'Start only if no bot runs; never stop the running one (used by autostart)')
|
|
93
96
|
.action(async (options) => {
|
|
@@ -124,6 +127,8 @@ export function registerStartCommand(cli: Goke) {
|
|
|
124
127
|
}
|
|
125
128
|
const dataDir = dataDirOrDefault(options.dataDir)
|
|
126
129
|
const urls = gatewayUrlsFromEnv()
|
|
130
|
+
const disabledSkills = parseDisabledSkills(options.disableSkills)
|
|
131
|
+
if (disabledSkills instanceof Error) fail(disabledSkills)
|
|
127
132
|
const machine = options.machineName ?? defaultMachineName()
|
|
128
133
|
// Validated before the lock, so a typo never stops the running bot. null: reset to the default.
|
|
129
134
|
const voice = (() => {
|
|
@@ -212,6 +217,8 @@ export function registerStartCommand(cli: Goke) {
|
|
|
212
217
|
opencodeDbFile: opencodeDbFile(),
|
|
213
218
|
analytics: createAnalytics({ dataDir, botMode: credentials.mode, enabled: !options.noAnalytics }),
|
|
214
219
|
autoWorktrees: Boolean(options.worktrees),
|
|
220
|
+
allowAllUsers: Boolean(options.allowAllUsers),
|
|
221
|
+
disabledSkills,
|
|
215
222
|
})
|
|
216
223
|
if (bot instanceof Error) failStartup(bot)
|
|
217
224
|
// exit() keeps process.exitCode: the restart route sets RESTART_EXIT_CODE before its SIGTERM.
|
|
@@ -239,6 +246,8 @@ export function registerStartCommand(cli: Goke) {
|
|
|
239
246
|
if (lock.port === DEFAULT_LOCK_PORT) {
|
|
240
247
|
const autostart = await writeAutostartScript({ dataDir, args: [
|
|
241
248
|
...(options.worktrees ? ['--worktrees'] : []),
|
|
249
|
+
...(options.allowAllUsers ? ['--allow-all-users'] : []),
|
|
250
|
+
...(options.disableSkills ? ['--disable-skills', options.disableSkills] : []),
|
|
242
251
|
...(options.noAnalytics ? ['--no-analytics'] : []),
|
|
243
252
|
...(options.machineName ? ['--machine-name', options.machineName] : []),
|
|
244
253
|
] })
|
package/src/cli/discord.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function registerDiscordCommands(cli: Goke) {
|
|
|
99
99
|
printRows({ json: options.json, rows: users, line: (user) => `${user.id} ${user.username}${user.name ? ` (${user.name})` : ''}` })
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
cli.command('upload-to-discord <...files>', 'Attach local files to a session thread or the Kimaki voice channel chat')
|
|
102
|
+
cli.command('upload-to-discord <...files>', 'Attach local files to a session thread or the Kimaki voice channel chat. Pass many files in one call: Discord shows them numbered in argument order')
|
|
103
103
|
.option('--data-dir <path>', DATA_DIR_HELP)
|
|
104
104
|
.option('-s, --session <id>', SESSION_HELP)
|
|
105
105
|
.option('-c, --channel <id>', 'Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)')
|
package/src/cli/session.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { wrapJsonSchema, type Goke } from 'goke'
|
|
|
11
11
|
import { parseDuration } from '../duration.ts'
|
|
12
12
|
import { OpenCodeError } from '../errors.ts'
|
|
13
13
|
import { editorsForFile, loadFileEditEvents } from '../file-edit-log.ts'
|
|
14
|
-
import { allMessages, allSessions, readSessionMarkdown, sessionEventsFile } from '../session-events.ts'
|
|
14
|
+
import { allMessages, allSessions, readSessionMarkdown, sessionEventsFile, syncTranscripts, transcriptsDir } from '../session-events.ts'
|
|
15
15
|
import {
|
|
16
16
|
action,
|
|
17
17
|
DATA_DIR_HELP,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
|
|
32
32
|
const execFileAsync = promisify(execFile)
|
|
33
33
|
|
|
34
|
-
// list,
|
|
34
|
+
// list, sync, wait, editors, diff, url
|
|
35
35
|
export function registerSessionQueryCommands(cli: Goke) {
|
|
36
36
|
cli.command('session list', 'List sessions with native status and token counts')
|
|
37
37
|
.option('--data-dir <path>', DATA_DIR_HELP)
|
|
@@ -65,54 +65,21 @@ export function registerSessionQueryCommands(cli: Goke) {
|
|
|
65
65
|
if (options.active && rows.length === 0) process.exitCode = 1
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
cli.command('session
|
|
68
|
+
cli.command('session sync', 'Write session transcripts to <data-dir>/transcripts for rg; skips current ones')
|
|
69
69
|
.option('--data-dir <path>', DATA_DIR_HELP)
|
|
70
|
-
.option('--
|
|
71
|
-
.option('
|
|
72
|
-
.option('--
|
|
73
|
-
.option('--days <n>', 'Recent days (default: 14; 0 = all)')
|
|
70
|
+
.option('--days <n>', 'Sessions updated in the last n days (default: 14)')
|
|
71
|
+
.option('--all', 'All sessions, any age')
|
|
72
|
+
.option('--rebuild', 'Write transcripts again, also current ones')
|
|
74
73
|
.option('--json', 'Output as JSON')
|
|
75
|
-
.action(async (
|
|
76
|
-
const
|
|
77
|
-
const directory = options.all ? undefined : await projectDirectory({ project: options.project, channel: options.channel, dataDir: options.dataDir })
|
|
78
|
-
const sessions = await allSessions({ client, directory })
|
|
79
|
-
if (sessions instanceof Error) fail(sessions)
|
|
80
|
-
// `/pattern/flags` is a regular expression, anything else a case-insensitive substring.
|
|
81
|
-
const pattern = query.match(/^\/(.*)\/([dgimsuvy]*)$/)
|
|
82
|
-
const expression = pattern ? new RegExp(pattern[1]!, pattern[2]) : null
|
|
83
|
-
const matches = (text: string) => {
|
|
84
|
-
if (!expression) return text.toLowerCase().includes(query.toLowerCase())
|
|
85
|
-
expression.lastIndex = 0
|
|
86
|
-
return expression.test(text)
|
|
87
|
-
}
|
|
88
|
-
const days = Number(options.days ?? 14)
|
|
74
|
+
.action(async (options) => {
|
|
75
|
+
const days = options.all ? 0 : Number(options.days ?? 14)
|
|
89
76
|
if (!Number.isFinite(days) || days < 0) fail(new Error('--days must be a non-negative number'))
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.catch((cause) => new OpenCodeError({ operation: 'message.list', cause }))
|
|
97
|
-
if (page instanceof Error) return page
|
|
98
|
-
if (page.data.some((message) => matches(message.type === 'user' ? message.text : JSON.stringify(message)))) return true
|
|
99
|
-
return page.cursor.next ? scan(page.cursor.next) : false
|
|
100
|
-
}
|
|
101
|
-
return scan(undefined)
|
|
102
|
-
}
|
|
103
|
-
// 8 sessions at a time.
|
|
104
|
-
const results: boolean[] = new Array(recent.length)
|
|
105
|
-
const next = { index: 0 }
|
|
106
|
-
await Promise.all(Array.from({ length: Math.min(8, recent.length) }, async () => {
|
|
107
|
-
while (next.index < recent.length) {
|
|
108
|
-
const index = next.index++
|
|
109
|
-
const result = await hit(recent[index]!)
|
|
110
|
-
if (result instanceof Error) fail(result)
|
|
111
|
-
results[index] = result
|
|
112
|
-
}
|
|
113
|
-
}))
|
|
114
|
-
const found = recent.filter((_, index) => results[index])
|
|
115
|
-
printRows({ json: options.json, rows: found, line: (session) => `${session.id} ${session.title}` })
|
|
77
|
+
const dataDir = dataDirOrDefault(options.dataDir)
|
|
78
|
+
const result = await syncTranscripts({ client: await readClient(), dataDir, days, rebuild: Boolean(options.rebuild) })
|
|
79
|
+
if (result instanceof Error) fail(result)
|
|
80
|
+
if (result.failed > 0) process.exitCode = 1
|
|
81
|
+
if (options.json) return printJson({ dir: transcriptsDir({ dataDir }), ...result })
|
|
82
|
+
process.stdout.write(`${transcriptsDir({ dataDir })}: ${result.written} written, ${result.skipped} skipped, ${result.failed} failed\n`)
|
|
116
83
|
})
|
|
117
84
|
|
|
118
85
|
cli.command('session wait <id>', 'Wait until idle or waiting for input, then print the session')
|
|
@@ -37,6 +37,10 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
|
|
|
37
37
|
const threads = await cli(['thread', 'list', '--channel', twin.channelId, '--json'])
|
|
38
38
|
const users = await cli(['user', 'list', '--guild', twin.discord.guildId, '--query', 'tommy', '--json'])
|
|
39
39
|
await cli(['upload-to-discord', path.join(server.root, 'report.txt'), '--session', started.sessionId])
|
|
40
|
+
// Many files keep argument order, numbered, images and files mixed.
|
|
41
|
+
fs.mkdirSync(path.join(server.root, 'other'), { recursive: true })
|
|
42
|
+
for (const name of ['b.png', 'my shot.png', 'other/b.png']) fs.writeFileSync(path.join(server.root, name), 'png')
|
|
43
|
+
await cli(['upload-to-discord', ...['b.png', 'report.txt', 'my shot.png', 'other/b.png'].map((name) => path.join(server.root, name)), '--session', started.sessionId])
|
|
40
44
|
// The agent learns that nothing was posted.
|
|
41
45
|
const missing = await cli(['upload-to-discord', path.join(server.root, 'missing.png'), '--session', started.sessionId]).catch((error: { stderr: string }) => error)
|
|
42
46
|
expect(missing.stderr.replace(server.root, '<root>')).toMatchInlineSnapshot(`
|
|
@@ -51,7 +55,20 @@ test('independent Discord CLI lists threads and users, uploads a file, and reads
|
|
|
51
55
|
-# *using deterministic-provider/deterministic-v2 ⋅ build*
|
|
52
56
|
ok
|
|
53
57
|
-# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
|
|
54
|
-
[
|
|
58
|
+
[file: attachment://report.txt]
|
|
59
|
+
[attachment: report.txt]
|
|
60
|
+
-# 1. \`b.png\`
|
|
61
|
+
[media: attachment://b.png]
|
|
62
|
+
-# 2. \`report.txt\`
|
|
63
|
+
[file: attachment://report.txt]
|
|
64
|
+
-# 3. \`my shot.png\`
|
|
65
|
+
[media: attachment://my_shot.png]
|
|
66
|
+
-# 4. \`b.png\`
|
|
67
|
+
[media: attachment://4-b.png]
|
|
68
|
+
[attachment: b.png]
|
|
69
|
+
[attachment: report.txt]
|
|
70
|
+
[attachment: my_shot.png]
|
|
71
|
+
[attachment: 4-b.png]"
|
|
55
72
|
`)
|
|
56
73
|
expect(threads.stdout).toContain(started.threadId)
|
|
57
74
|
expect(users.stdout).toContain(TEST_USER_ID)
|
package/src/cli-lock.e2e.test.ts
CHANGED
|
@@ -89,12 +89,11 @@ test('CLI help documents the supported P7 commands', async () => {
|
|
|
89
89
|
--exclude <id> Exclude session (repeatable)
|
|
90
90
|
--json Output as JSON
|
|
91
91
|
|
|
92
|
-
session
|
|
92
|
+
session sync Write session transcripts to <data-dir>/transcripts for rg; skips current ones
|
|
93
93
|
--data-dir <path> Data directory (default: ~/.kimaki)
|
|
94
|
-
--
|
|
95
|
-
|
|
96
|
-
--
|
|
97
|
-
--days <n> Recent days (default: 14; 0 = all)
|
|
94
|
+
--days <n> Sessions updated in the last n days (default: 14)
|
|
95
|
+
--all All sessions, any age
|
|
96
|
+
--rebuild Write transcripts again, also current ones
|
|
98
97
|
--json Output as JSON
|
|
99
98
|
|
|
100
99
|
session wait <id> Wait until idle or waiting for input, then print the session
|
|
@@ -316,7 +315,7 @@ test('CLI help documents the supported P7 commands', async () => {
|
|
|
316
315
|
-q, --query <text> Name filter
|
|
317
316
|
--json Output as JSON
|
|
318
317
|
|
|
319
|
-
upload-to-discord <...files> Attach local files to a session thread or the Kimaki voice channel chat
|
|
318
|
+
upload-to-discord <...files> Attach local files to a session thread or the Kimaki voice channel chat. Pass many files in one call: Discord shows them numbered in argument order
|
|
320
319
|
--data-dir <path> Data directory (default: ~/.kimaki)
|
|
321
320
|
-s, --session <id> Session ID, Discord thread ID or thread URL (default: OPENCODE_SESSION_ID)
|
|
322
321
|
-c, --channel <id> Kimaki voice channel to post in (default in voice calls: KIMAKI_CHANNEL_ID)
|
|
@@ -376,7 +375,9 @@ test('CLI help documents the supported P7 commands', async () => {
|
|
|
376
375
|
--machine-name <name> Name in this machine's category "Kimaki <name>" (default: hostname)
|
|
377
376
|
--restart-onboarding Choose credentials again
|
|
378
377
|
--worktrees Use a fresh Git worktree for new sessions unless the channel overrides it
|
|
378
|
+
--allow-all-users Every server member can use the bot (role "no-kimaki" still denies; provider login stays admin-only)
|
|
379
379
|
--voice <name> Voice of voice calls, saved for later starts (e.g. cove, juniper, sol). "default" resets it
|
|
380
|
+
--disable-skills <list> Built-in Kimaki skills to turn off: "all" or a comma separated list (kimaki-debug,kimaki-tunnel)
|
|
380
381
|
--no-analytics Disable anonymous usage analytics (same as KIMAKI_STRADA_ENABLED=0)
|
|
381
382
|
--if-not-running Start only if no bot runs; never stop the running one (used by autostart)
|
|
382
383
|
-h, --help Display this message
|
package/src/cli-read.e2e.test.ts
CHANGED
|
@@ -28,13 +28,15 @@ function cli(args: string[]) {
|
|
|
28
28
|
cwd: process.cwd(), env: { ...process.env, KIMAKI_LOCK_PORT: String(bot.lock.port), KIMAKI_OPENCODE_SERVICE_FILE: server.serviceFile, KIMAKI_DISCORD_REST_URL: twin.discord.restUrl },
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
|
-
test('session read, list,
|
|
31
|
+
test('session read, list, sync, wait, url and events read real session history', async () => {
|
|
32
32
|
const first = await send(bot, { channelId: twin.channelId, prompt: 'Reader content marker' })
|
|
33
33
|
if (first instanceof Error) throw first
|
|
34
34
|
if (!first.sessionId) throw new Error('Expected an AI session')
|
|
35
35
|
await waitForFooter({ discord: twin.discord, threadId: first.threadId })
|
|
36
36
|
const list = await cli(['session', 'list', '--project', server.projectDirectory, '--json'])
|
|
37
|
-
const
|
|
37
|
+
const sync = JSON.parse((await cli(['session', 'sync', '--days', '1', '--json'])).stdout)
|
|
38
|
+
const again = JSON.parse((await cli(['session', 'sync', '--days', '1', '--json'])).stdout)
|
|
39
|
+
const rebuilt = JSON.parse((await cli(['session', 'sync', '--days', '1', '--rebuild', '--json'])).stdout)
|
|
38
40
|
const read = await cli(['session', 'read', first.threadId])
|
|
39
41
|
const wait = await cli(['session', 'wait', first.sessionId, '--timeout', '10s'])
|
|
40
42
|
const url = await cli(['session', 'url', first.sessionId])
|
|
@@ -47,7 +49,10 @@ test('session read, list, search, wait, url and events read real session history
|
|
|
47
49
|
-# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
|
|
48
50
|
`)
|
|
49
51
|
expect(list.stdout).toContain(first.sessionId)
|
|
50
|
-
expect(
|
|
52
|
+
expect(sync.written + sync.skipped).toBeGreaterThan(0)
|
|
53
|
+
expect([again.written, again.failed, rebuilt.skipped, rebuilt.failed]).toEqual([0, 0, 0, 0])
|
|
54
|
+
expect(fs.readFileSync(path.join(dataDir, 'transcripts', `${first.sessionId}.md`), 'utf8')).toContain('## user')
|
|
55
|
+
expect(fs.readFileSync(path.join(dataDir, 'transcripts', `${first.sessionId}.md`), 'utf8')).toContain('Reader content marker')
|
|
51
56
|
expect(read.stdout).toContain('Reader content marker')
|
|
52
57
|
expect(wait.stdout).toContain('Reader content marker')
|
|
53
58
|
expect(url.stdout).toContain(first.threadId)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// CLI session commands: editors,
|
|
1
|
+
// CLI session commands: editors, command, named fork, resume by folder.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
@@ -68,16 +68,14 @@ test('session diff prints critique output for the session folder', async () => {
|
|
|
68
68
|
expect(diff.stdout).toContain('No changes to display')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
test('session editors lists sessions that wrote a file, skips failed tool calls
|
|
71
|
+
test('session editors lists sessions that wrote a file, skips failed tool calls', async () => {
|
|
72
72
|
const writer = await newSession('editor-marker write')
|
|
73
73
|
// An edit of a missing file fails and must not be recorded.
|
|
74
74
|
await newSession('fail-marker edit')
|
|
75
75
|
const written = await cli(['session', 'editors', 'edited.txt', '--json'])
|
|
76
76
|
const failed = await cli(['session', 'editors', path.join(server.projectDirectory, 'missing.txt')]).catch((error: Error & { code: number }) => error)
|
|
77
|
-
const searched = await cli(['session', 'search', 'editor-marker', '--channel', twin.channelId])
|
|
78
77
|
expect(JSON.parse(written.stdout).map((row: { sessionId: string }) => row.sessionId)).toEqual([writer.sessionId])
|
|
79
78
|
expect(failed instanceof Error && failed.code).toBe(1)
|
|
80
|
-
expect(searched.stdout).toContain(writer.sessionId)
|
|
81
79
|
})
|
|
82
80
|
|
|
83
81
|
test('session command, named fork, and resume without a channel use the session folder', async () => {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// keyed by a short hash (custom IDs max 100 chars), dropped after 10 minutes.
|
|
16
16
|
|
|
17
17
|
import crypto from 'node:crypto'
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
18
19
|
import * as orm from 'drizzle-orm'
|
|
19
20
|
import {
|
|
20
21
|
SlashCommandBuilder,
|
|
@@ -139,7 +140,11 @@ async function current(bot: Bot, target: InteractionTarget) {
|
|
|
139
140
|
if (model) return { scope: 'channel' as const, modelScope: 'channel' as const, agent, model }
|
|
140
141
|
const global = await globalModel(bot)
|
|
141
142
|
if (global instanceof Error) return global
|
|
142
|
-
return { scope: 'channel' as const, modelScope: 'global' as const, agent, model: global }
|
|
143
|
+
if (global) return { scope: 'channel' as const, modelScope: 'global' as const, agent, model: global }
|
|
144
|
+
const fallback = await oc(bot, 'model.default', (client) => client.model.default({ location: { directory: target.directory } }))
|
|
145
|
+
if (fallback instanceof Error) return fallback
|
|
146
|
+
const defaultModel = fallback.data ? { providerID: fallback.data.providerID, id: fallback.data.id, variant: null } : null
|
|
147
|
+
return { scope: 'channel' as const, modelScope: 'OpenCode default' as const, agent, model: defaultModel }
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
// The model a new session of the channel starts with, like startSession:
|
|
@@ -193,10 +198,18 @@ async function resetButtons(bot: Bot, { target, hash }: { target: InteractionTar
|
|
|
193
198
|
|
|
194
199
|
async function enabledModels(bot: Bot, directory: string) {
|
|
195
200
|
const location = { directory }
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
const load = () =>
|
|
202
|
+
Promise.all([
|
|
203
|
+
oc(bot, 'model.list', (client) => client.model.list({ location })),
|
|
204
|
+
oc(bot, 'provider.list', (client) => client.provider.list({ location })),
|
|
205
|
+
])
|
|
206
|
+
let [models, providers] = await load()
|
|
207
|
+
// TODO: drop the retry once OpenCode awaits location load. model.list and provider.list return
|
|
208
|
+
// empty data on the first call for a cold location (reproduced on v2.0.26 with a fresh directory).
|
|
209
|
+
for (let attempt = 0; attempt < 5 && !(models instanceof Error) && models.data.length === 0; attempt++) {
|
|
210
|
+
await sleep(300)
|
|
211
|
+
;[models, providers] = await load()
|
|
212
|
+
}
|
|
200
213
|
if (models instanceof Error) return models
|
|
201
214
|
if (providers instanceof Error) return providers
|
|
202
215
|
const names = new Map(providers.data.map((provider) => [provider.id, provider.name]))
|
|
@@ -363,7 +376,7 @@ async function renderStep(
|
|
|
363
376
|
const [now, resets] = await Promise.all([current(bot, wizard.target), resetButtons(bot, { target: wizard.target, hash })])
|
|
364
377
|
if (now instanceof Error) return replyError(interaction, now)
|
|
365
378
|
if (resets instanceof Error) return replyError(interaction, resets)
|
|
366
|
-
const currentText = now.model ? `**Current (${now.modelScope}):** \`${modelLabel(now.model)}\`` : '**Current:**
|
|
379
|
+
const currentText = now.model ? `**Current (${now.modelScope}):** \`${modelLabel(now.model)}\`` : '**Current:** none configured'
|
|
367
380
|
return reply(
|
|
368
381
|
`${header}\n${currentText}\nSelect a provider:`,
|
|
369
382
|
providers.map(([id, name]) => {
|
package/src/effects.ts
CHANGED
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
import { execFile } from 'node:child_process'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ComponentType,
|
|
21
|
+
MessageFlags,
|
|
22
|
+
type APIMessageTopLevelComponent,
|
|
23
|
+
type Client,
|
|
24
|
+
type MessageCreateOptions,
|
|
25
|
+
type SendableChannels,
|
|
26
|
+
} from 'discord.js'
|
|
20
27
|
|
|
21
28
|
import { formatFooter, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, type UiMessage } from './format-parts.ts'
|
|
22
29
|
import { createLogger } from './logger.ts'
|
|
@@ -83,6 +90,35 @@ function uploadBatches(files: readonly UploadFile[]): UploadFile[][] {
|
|
|
83
90
|
return batches.map((batch) => batch.files)
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
// Media Gallery takes images and videos; everything else is a File component.
|
|
94
|
+
const GALLERY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.webm', '.mov'])
|
|
95
|
+
|
|
96
|
+
// Plain `files` let the Discord client group images into a mosaic apart from
|
|
97
|
+
// other files. Components V2 place each file explicitly, so the thread shows
|
|
98
|
+
// them in argument order. With several files each one gets a numbered label,
|
|
99
|
+
// so the user and the agent can point at "file 3".
|
|
100
|
+
// https://docs.discord.com/developers/components/reference#media-gallery
|
|
101
|
+
function uploadMessage({ files, first, numbered }: { files: readonly UploadFile[]; first: number; numbered: boolean }) {
|
|
102
|
+
const used = new Set<string>()
|
|
103
|
+
const components: APIMessageTopLevelComponent[] = []
|
|
104
|
+
const attachments = files.map((file, index) => {
|
|
105
|
+
const number = first + index
|
|
106
|
+
// attachment:// needs a unique name Discord keeps as is.
|
|
107
|
+
const safe = file.name.replace(/[^\w.-]/g, '_') || 'file'
|
|
108
|
+
const name = used.has(safe) ? `${number}-${safe}` : safe
|
|
109
|
+
used.add(name)
|
|
110
|
+
if (numbered) components.push({ type: ComponentType.TextDisplay, content: `-# ${number}. \`${file.name}\`` })
|
|
111
|
+
const media = { url: `attachment://${name}` }
|
|
112
|
+
components.push(
|
|
113
|
+
GALLERY_EXTENSIONS.has(path.extname(name).toLowerCase())
|
|
114
|
+
? { type: ComponentType.MediaGallery, items: [{ media }] }
|
|
115
|
+
: { type: ComponentType.File, file: media },
|
|
116
|
+
)
|
|
117
|
+
return { attachment: file.path, name }
|
|
118
|
+
})
|
|
119
|
+
return { files: attachments, components, flags: MessageFlags.IsComponentsV2 | SILENT_MESSAGE_FLAGS }
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
// Effects that will never run: an upload still waits for its answer.
|
|
87
123
|
function drop(effects: readonly Effect[]) {
|
|
88
124
|
for (const effect of effects) {
|
|
@@ -141,18 +177,16 @@ export function createEffectsRunner({ discord, markViewed }: { discord: Client;
|
|
|
141
177
|
|
|
142
178
|
// Unlike post(), the error goes back to the agent that uploaded.
|
|
143
179
|
async function postFiles(threadId: string, files: readonly UploadFile[]): Promise<Error | null> {
|
|
180
|
+
let first = 1
|
|
144
181
|
for (const batch of uploadBatches(files)) {
|
|
145
182
|
if (lifecycle.closed) return new DiscordError({ operation: 'upload (bot stopped)' })
|
|
146
183
|
const channel = await sendableChannel(threadId)
|
|
147
184
|
if (!channel) return new DiscordError({ operation: `upload to thread ${threadId}` })
|
|
148
185
|
const sent = await channel
|
|
149
|
-
.send({
|
|
150
|
-
files: batch.map((file) => ({ attachment: file.path, name: file.name })),
|
|
151
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
152
|
-
allowedMentions: { parse: [] },
|
|
153
|
-
})
|
|
186
|
+
.send({ ...uploadMessage({ files: batch, first, numbered: files.length > 1 }), allowedMentions: { parse: [] } })
|
|
154
187
|
.catch((cause: Error) => new DiscordError({ operation: `upload of ${batch.map((file) => file.name).join(', ')} (${cause.message})`, cause }))
|
|
155
188
|
if (sent instanceof Error) return sent
|
|
189
|
+
first += batch.length
|
|
156
190
|
}
|
|
157
191
|
return null
|
|
158
192
|
}
|
package/src/errors.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class OpenCodeMissingError extends errore.createTaggedError({
|
|
|
20
20
|
// Both install commands put OpenCode 2 at `opencode`.
|
|
21
21
|
export class OpenCodeV1Error extends errore.createTaggedError({
|
|
22
22
|
name: 'OpenCodeV1Error',
|
|
23
|
-
message: 'Kimaki now requires OpenCode 2, but $binary is OpenCode $version. Install OpenCode 2 with: $install (or: npm i -g @opencode/cli). Then run kimaki again',
|
|
23
|
+
message: 'Kimaki now requires OpenCode 2, but $binary is OpenCode $version. Install OpenCode 2 with: $install (or: npm i -g @opencode/cli). Then run kimaki again. To keep using OpenCode 1, run the older Kimaki instead: npx kimaki@0',
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
26
26
|
export class OpenCodeVersionError extends errore.createTaggedError({
|
package/src/event-loop.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function createEventLoop({
|
|
|
68
68
|
recorder,
|
|
69
69
|
analytics,
|
|
70
70
|
onRunEnded,
|
|
71
|
+
onTranscriptDue,
|
|
71
72
|
}: {
|
|
72
73
|
store: BotStore
|
|
73
74
|
db: KimakiDb
|
|
@@ -77,6 +78,8 @@ export function createEventLoop({
|
|
|
77
78
|
analytics: Analytics
|
|
78
79
|
// Every finished or failed run of a thread's root session (voice call notifications).
|
|
79
80
|
onRunEnded: (run: RunEnded) => void
|
|
81
|
+
// A root session stopped running (also after an interrupt): its transcript file is stale.
|
|
82
|
+
onTranscriptDue: (sessionId: string) => void
|
|
80
83
|
}) {
|
|
81
84
|
// The client of the current connection, set before hydration starts.
|
|
82
85
|
const connection: { client: OpenCodeClient | null } = { client: null }
|
|
@@ -159,6 +162,12 @@ export function createEventLoop({
|
|
|
159
162
|
if ((event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed') && store.getState().roots[threadId] === event.data.sessionID) {
|
|
160
163
|
onRunEnded({ threadId, sessionId: event.data.sessionID, error: event.type === 'session.execution.failed' ? event.data.error.message : null })
|
|
161
164
|
}
|
|
165
|
+
if (
|
|
166
|
+
(event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed' || event.type === 'session.execution.interrupted') &&
|
|
167
|
+
store.getState().roots[threadId] === event.data.sessionID
|
|
168
|
+
) {
|
|
169
|
+
onTranscriptDue(event.data.sessionID)
|
|
170
|
+
}
|
|
162
171
|
if (store.getState().threads[threadId] && !coldThreads.has(threadId)) return fold(threadId, event)
|
|
163
172
|
hold(coldThreads, threadId, event)
|
|
164
173
|
void loadView(threadId)
|