kimaki 0.4.30 → 0.4.31
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/LICENSE +21 -0
- package/dist/cli.js +70 -37
- package/dist/commands/ask-question.js +49 -16
- package/dist/discord-bot.js +4 -1
- package/dist/discord-utils.js +4 -1
- package/dist/escape-backticks.test.js +11 -3
- package/dist/session-handler.js +17 -3
- package/dist/system-message.js +4 -4
- package/dist/unnest-code-blocks.js +110 -0
- package/dist/unnest-code-blocks.test.js +213 -0
- package/dist/utils.js +1 -0
- package/package.json +11 -12
- package/src/cli.ts +91 -46
- package/src/commands/ask-question.ts +57 -22
- package/src/discord-bot.ts +4 -1
- package/src/discord-utils.ts +4 -1
- package/src/escape-backticks.test.ts +11 -3
- package/src/session-handler.ts +19 -3
- package/src/system-message.ts +4 -4
- package/src/unnest-code-blocks.test.ts +225 -0
- package/src/unnest-code-blocks.ts +127 -0
- package/src/utils.ts +1 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { test, expect } from 'vitest';
|
|
2
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
3
|
+
test('basic - single item with code block', () => {
|
|
4
|
+
const input = `- Item 1
|
|
5
|
+
\`\`\`js
|
|
6
|
+
const x = 1
|
|
7
|
+
\`\`\``;
|
|
8
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
9
|
+
expect(result).toMatchInlineSnapshot(`
|
|
10
|
+
"- Item 1
|
|
11
|
+
|
|
12
|
+
\`\`\`js
|
|
13
|
+
const x = 1
|
|
14
|
+
\`\`\`
|
|
15
|
+
"
|
|
16
|
+
`);
|
|
17
|
+
});
|
|
18
|
+
test('multiple items - code in middle item only', () => {
|
|
19
|
+
const input = `- Item 1
|
|
20
|
+
- Item 2
|
|
21
|
+
\`\`\`js
|
|
22
|
+
const x = 1
|
|
23
|
+
\`\`\`
|
|
24
|
+
- Item 3`;
|
|
25
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
26
|
+
expect(result).toMatchInlineSnapshot(`
|
|
27
|
+
"- Item 1
|
|
28
|
+
- Item 2
|
|
29
|
+
|
|
30
|
+
\`\`\`js
|
|
31
|
+
const x = 1
|
|
32
|
+
\`\`\`
|
|
33
|
+
- Item 3"
|
|
34
|
+
`);
|
|
35
|
+
});
|
|
36
|
+
test('multiple code blocks in one item', () => {
|
|
37
|
+
const input = `- Item with two code blocks
|
|
38
|
+
\`\`\`js
|
|
39
|
+
const a = 1
|
|
40
|
+
\`\`\`
|
|
41
|
+
\`\`\`python
|
|
42
|
+
b = 2
|
|
43
|
+
\`\`\``;
|
|
44
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
45
|
+
expect(result).toMatchInlineSnapshot(`
|
|
46
|
+
"- Item with two code blocks
|
|
47
|
+
|
|
48
|
+
\`\`\`js
|
|
49
|
+
const a = 1
|
|
50
|
+
\`\`\`
|
|
51
|
+
\`\`\`python
|
|
52
|
+
b = 2
|
|
53
|
+
\`\`\`
|
|
54
|
+
"
|
|
55
|
+
`);
|
|
56
|
+
});
|
|
57
|
+
test('nested list with code', () => {
|
|
58
|
+
const input = `- Item 1
|
|
59
|
+
- Nested item
|
|
60
|
+
\`\`\`js
|
|
61
|
+
const x = 1
|
|
62
|
+
\`\`\`
|
|
63
|
+
- Item 2`;
|
|
64
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
65
|
+
expect(result).toMatchInlineSnapshot(`
|
|
66
|
+
"- Item 1
|
|
67
|
+
- Nested item
|
|
68
|
+
|
|
69
|
+
\`\`\`js
|
|
70
|
+
const x = 1
|
|
71
|
+
\`\`\`
|
|
72
|
+
- Item 2"
|
|
73
|
+
`);
|
|
74
|
+
});
|
|
75
|
+
test('ordered list preserves numbering', () => {
|
|
76
|
+
const input = `1. First item
|
|
77
|
+
\`\`\`js
|
|
78
|
+
const a = 1
|
|
79
|
+
\`\`\`
|
|
80
|
+
2. Second item
|
|
81
|
+
3. Third item`;
|
|
82
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
83
|
+
expect(result).toMatchInlineSnapshot(`
|
|
84
|
+
"1. First item
|
|
85
|
+
|
|
86
|
+
\`\`\`js
|
|
87
|
+
const a = 1
|
|
88
|
+
\`\`\`
|
|
89
|
+
2. Second item
|
|
90
|
+
3. Third item"
|
|
91
|
+
`);
|
|
92
|
+
});
|
|
93
|
+
test('list without code blocks unchanged', () => {
|
|
94
|
+
const input = `- Item 1
|
|
95
|
+
- Item 2
|
|
96
|
+
- Item 3`;
|
|
97
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
98
|
+
expect(result).toMatchInlineSnapshot(`
|
|
99
|
+
"- Item 1
|
|
100
|
+
- Item 2
|
|
101
|
+
- Item 3"
|
|
102
|
+
`);
|
|
103
|
+
});
|
|
104
|
+
test('mixed - some items have code, some dont', () => {
|
|
105
|
+
const input = `- Normal item
|
|
106
|
+
- Item with code
|
|
107
|
+
\`\`\`js
|
|
108
|
+
const x = 1
|
|
109
|
+
\`\`\`
|
|
110
|
+
- Another normal item
|
|
111
|
+
- Another with code
|
|
112
|
+
\`\`\`python
|
|
113
|
+
y = 2
|
|
114
|
+
\`\`\``;
|
|
115
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
116
|
+
expect(result).toMatchInlineSnapshot(`
|
|
117
|
+
"- Normal item
|
|
118
|
+
- Item with code
|
|
119
|
+
|
|
120
|
+
\`\`\`js
|
|
121
|
+
const x = 1
|
|
122
|
+
\`\`\`
|
|
123
|
+
- Another normal item
|
|
124
|
+
- Another with code
|
|
125
|
+
|
|
126
|
+
\`\`\`python
|
|
127
|
+
y = 2
|
|
128
|
+
\`\`\`
|
|
129
|
+
"
|
|
130
|
+
`);
|
|
131
|
+
});
|
|
132
|
+
test('text before and after code in same item', () => {
|
|
133
|
+
const input = `- Start text
|
|
134
|
+
\`\`\`js
|
|
135
|
+
const x = 1
|
|
136
|
+
\`\`\`
|
|
137
|
+
End text`;
|
|
138
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
139
|
+
expect(result).toMatchInlineSnapshot(`
|
|
140
|
+
"- Start text
|
|
141
|
+
|
|
142
|
+
\`\`\`js
|
|
143
|
+
const x = 1
|
|
144
|
+
\`\`\`
|
|
145
|
+
- End text
|
|
146
|
+
"
|
|
147
|
+
`);
|
|
148
|
+
});
|
|
149
|
+
test('preserves content outside lists', () => {
|
|
150
|
+
const input = `# Heading
|
|
151
|
+
|
|
152
|
+
Some paragraph text.
|
|
153
|
+
|
|
154
|
+
- List item
|
|
155
|
+
\`\`\`js
|
|
156
|
+
const x = 1
|
|
157
|
+
\`\`\`
|
|
158
|
+
|
|
159
|
+
More text after.`;
|
|
160
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
161
|
+
expect(result).toMatchInlineSnapshot(`
|
|
162
|
+
"# Heading
|
|
163
|
+
|
|
164
|
+
Some paragraph text.
|
|
165
|
+
|
|
166
|
+
- List item
|
|
167
|
+
|
|
168
|
+
\`\`\`js
|
|
169
|
+
const x = 1
|
|
170
|
+
\`\`\`
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
More text after."
|
|
174
|
+
`);
|
|
175
|
+
});
|
|
176
|
+
test('code block at root level unchanged', () => {
|
|
177
|
+
const input = `\`\`\`js
|
|
178
|
+
const x = 1
|
|
179
|
+
\`\`\``;
|
|
180
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
181
|
+
expect(result).toMatchInlineSnapshot(`
|
|
182
|
+
"\`\`\`js
|
|
183
|
+
const x = 1
|
|
184
|
+
\`\`\`"
|
|
185
|
+
`);
|
|
186
|
+
});
|
|
187
|
+
test('handles code block without language', () => {
|
|
188
|
+
const input = `- Item
|
|
189
|
+
\`\`\`
|
|
190
|
+
plain code
|
|
191
|
+
\`\`\``;
|
|
192
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
193
|
+
expect(result).toMatchInlineSnapshot(`
|
|
194
|
+
"- Item
|
|
195
|
+
|
|
196
|
+
\`\`\`
|
|
197
|
+
plain code
|
|
198
|
+
\`\`\`
|
|
199
|
+
"
|
|
200
|
+
`);
|
|
201
|
+
});
|
|
202
|
+
test('handles empty list item with code', () => {
|
|
203
|
+
const input = `- \`\`\`js
|
|
204
|
+
const x = 1
|
|
205
|
+
\`\`\``;
|
|
206
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
207
|
+
expect(result).toMatchInlineSnapshot(`
|
|
208
|
+
"\`\`\`js
|
|
209
|
+
const x = 1
|
|
210
|
+
\`\`\`
|
|
211
|
+
"
|
|
212
|
+
`);
|
|
213
|
+
});
|
package/dist/utils.js
CHANGED
|
@@ -17,6 +17,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
|
|
|
17
17
|
PermissionsBitField.Flags.AttachFiles,
|
|
18
18
|
PermissionsBitField.Flags.Connect,
|
|
19
19
|
PermissionsBitField.Flags.Speak,
|
|
20
|
+
PermissionsBitField.Flags.ManageRoles,
|
|
20
21
|
], scopes = ['bot'], guildId, disableGuildSelect = false, }) {
|
|
21
22
|
const permissionsBitField = new PermissionsBitField(permissions);
|
|
22
23
|
const permissionsValue = permissionsBitField.bitfield.toString();
|
package/package.json
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
|
-
"prepublishOnly": "pnpm tsc",
|
|
9
|
-
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
10
|
-
"watch": "tsx scripts/watch-session.ts",
|
|
11
|
-
"test:events": "tsx test-events.ts",
|
|
12
|
-
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
13
|
-
"test:send": "tsx send-test-message.ts",
|
|
14
|
-
"register-commands": "tsx scripts/register-commands.ts"
|
|
15
|
-
},
|
|
5
|
+
"version": "0.4.31",
|
|
16
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
17
7
|
"bin": "bin.js",
|
|
18
8
|
"files": [
|
|
@@ -55,5 +45,14 @@
|
|
|
55
45
|
"string-dedent": "^3.0.2",
|
|
56
46
|
"undici": "^7.16.0",
|
|
57
47
|
"zod": "^4.2.1"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
51
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
52
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
53
|
+
"test:events": "tsx test-events.ts",
|
|
54
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
55
|
+
"test:send": "tsx send-test-message.ts",
|
|
56
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
58
57
|
}
|
|
59
|
-
}
|
|
58
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -529,8 +529,14 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
529
529
|
}
|
|
530
530
|
|
|
531
531
|
const s = spinner()
|
|
532
|
-
s.start('Creating Discord client and connecting...')
|
|
533
532
|
|
|
533
|
+
// Start OpenCode server EARLY - let it initialize in parallel with Discord login.
|
|
534
|
+
// This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
|
|
535
|
+
const currentDir = process.cwd()
|
|
536
|
+
s.start('Starting OpenCode server...')
|
|
537
|
+
const opencodePromise = initializeOpencodeForDirectory(currentDir)
|
|
538
|
+
|
|
539
|
+
s.message('Connecting to Discord...')
|
|
534
540
|
const discordClient = await createDiscordClient()
|
|
535
541
|
|
|
536
542
|
const guilds: Guild[] = []
|
|
@@ -542,15 +548,56 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
542
548
|
discordClient.once(Events.ClientReady, async (c) => {
|
|
543
549
|
guilds.push(...Array.from(c.guilds.cache.values()))
|
|
544
550
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
+
// Process all guilds in parallel for faster startup
|
|
552
|
+
const guildResults = await Promise.all(
|
|
553
|
+
guilds.map(async (guild) => {
|
|
554
|
+
// Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
|
|
555
|
+
guild.roles
|
|
556
|
+
.fetch()
|
|
557
|
+
.then(async (roles) => {
|
|
558
|
+
const existingRole = roles.find(
|
|
559
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
560
|
+
)
|
|
561
|
+
if (existingRole) {
|
|
562
|
+
// Move to bottom if not already there
|
|
563
|
+
if (existingRole.position > 1) {
|
|
564
|
+
await existingRole.setPosition(1)
|
|
565
|
+
cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`)
|
|
566
|
+
}
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
return guild.roles.create({
|
|
570
|
+
name: 'Kimaki',
|
|
571
|
+
position: 1, // Place at bottom so anyone with Manage Roles can assign it
|
|
572
|
+
reason:
|
|
573
|
+
'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
.then((role) => {
|
|
577
|
+
if (role) {
|
|
578
|
+
cliLogger.info(`Created "Kimaki" role in ${guild.name}`)
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
.catch((error) => {
|
|
582
|
+
cliLogger.warn(
|
|
583
|
+
`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
584
|
+
)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
const channels = await getChannelsWithDescriptions(guild)
|
|
588
|
+
const kimakiChans = channels.filter(
|
|
589
|
+
(ch) =>
|
|
590
|
+
ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
return { guild, channels: kimakiChans }
|
|
594
|
+
}),
|
|
595
|
+
)
|
|
551
596
|
|
|
552
|
-
|
|
553
|
-
|
|
597
|
+
// Collect results
|
|
598
|
+
for (const result of guildResults) {
|
|
599
|
+
if (result.channels.length > 0) {
|
|
600
|
+
kimakiChannels.push(result)
|
|
554
601
|
}
|
|
555
602
|
}
|
|
556
603
|
|
|
@@ -613,32 +660,25 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
613
660
|
note(channelList, 'Existing Kimaki Channels')
|
|
614
661
|
}
|
|
615
662
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
s.stop('OpenCode server started!')
|
|
663
|
+
// Await the OpenCode server that was started in parallel with Discord login
|
|
664
|
+
s.start('Waiting for OpenCode server...')
|
|
665
|
+
const getClient = await opencodePromise
|
|
666
|
+
s.stop('OpenCode server ready!')
|
|
621
667
|
|
|
622
|
-
s.start('Fetching OpenCode
|
|
668
|
+
s.start('Fetching OpenCode data...')
|
|
623
669
|
|
|
624
|
-
|
|
670
|
+
// Fetch projects and commands in parallel
|
|
671
|
+
const [projects, allUserCommands] = await Promise.all([
|
|
672
|
+
getClient().project.list({}).then((r) => r.data || []).catch((error) => {
|
|
673
|
+
s.stop('Failed to fetch projects')
|
|
674
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
675
|
+
discordClient.destroy()
|
|
676
|
+
process.exit(EXIT_NO_RESTART)
|
|
677
|
+
}),
|
|
678
|
+
getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
|
|
679
|
+
])
|
|
625
680
|
|
|
626
|
-
|
|
627
|
-
const projectsResponse = await getClient().project.list({})
|
|
628
|
-
if (!projectsResponse.data) {
|
|
629
|
-
throw new Error('Failed to fetch projects')
|
|
630
|
-
}
|
|
631
|
-
projects = projectsResponse.data
|
|
632
|
-
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
633
|
-
} catch (error) {
|
|
634
|
-
s.stop('Failed to fetch projects')
|
|
635
|
-
cliLogger.error(
|
|
636
|
-
'Error:',
|
|
637
|
-
error instanceof Error ? error.message : String(error),
|
|
638
|
-
)
|
|
639
|
-
discordClient.destroy()
|
|
640
|
-
process.exit(EXIT_NO_RESTART)
|
|
641
|
-
}
|
|
681
|
+
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
642
682
|
|
|
643
683
|
const existingDirs = kimakiChannels.flatMap(({ channels }) =>
|
|
644
684
|
channels
|
|
@@ -746,19 +786,6 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
746
786
|
}
|
|
747
787
|
}
|
|
748
788
|
|
|
749
|
-
// Fetch user-defined commands using the already-running server
|
|
750
|
-
const allUserCommands: OpencodeCommand[] = []
|
|
751
|
-
try {
|
|
752
|
-
const commandsResponse = await getClient().command.list({
|
|
753
|
-
query: { directory: currentDir },
|
|
754
|
-
})
|
|
755
|
-
if (commandsResponse.data) {
|
|
756
|
-
allUserCommands.push(...commandsResponse.data)
|
|
757
|
-
}
|
|
758
|
-
} catch {
|
|
759
|
-
// Ignore errors fetching commands
|
|
760
|
-
}
|
|
761
|
-
|
|
762
789
|
// Log available user commands
|
|
763
790
|
const registrableCommands = allUserCommands.filter(
|
|
764
791
|
(cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
|
|
@@ -839,13 +866,31 @@ cli
|
|
|
839
866
|
'--data-dir <path>',
|
|
840
867
|
'Data directory for config and database (default: ~/.kimaki)',
|
|
841
868
|
)
|
|
842
|
-
.
|
|
869
|
+
.option('--install-url', 'Print the bot install URL and exit')
|
|
870
|
+
.action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
|
|
843
871
|
try {
|
|
844
872
|
// Set data directory early, before any database access
|
|
845
873
|
if (options.dataDir) {
|
|
846
874
|
setDataDir(options.dataDir)
|
|
847
875
|
cliLogger.log(`Using data directory: ${getDataDir()}`)
|
|
848
876
|
}
|
|
877
|
+
|
|
878
|
+
if (options.installUrl) {
|
|
879
|
+
const db = getDatabase()
|
|
880
|
+
const existingBot = db
|
|
881
|
+
.prepare(
|
|
882
|
+
'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
883
|
+
)
|
|
884
|
+
.get() as { app_id: string } | undefined
|
|
885
|
+
|
|
886
|
+
if (!existingBot) {
|
|
887
|
+
cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
|
|
888
|
+
process.exit(EXIT_NO_RESTART)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
|
|
892
|
+
process.exit(0)
|
|
893
|
+
}
|
|
849
894
|
|
|
850
895
|
await checkSingleInstance()
|
|
851
896
|
await startLockServer()
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from 'discord.js'
|
|
11
11
|
import crypto from 'node:crypto'
|
|
12
12
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
13
|
-
import {
|
|
13
|
+
import { getOpencodeClientV2 } from '../opencode.js'
|
|
14
14
|
import { createLogger } from '../logger.js'
|
|
15
15
|
|
|
16
16
|
const logger = createLogger('ASK_QUESTION')
|
|
@@ -200,31 +200,20 @@ async function submitQuestionAnswers(
|
|
|
200
200
|
context: PendingQuestionContext
|
|
201
201
|
): Promise<void> {
|
|
202
202
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return context.answers[i] || []
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// Reply to the question using direct HTTP call to OpenCode API
|
|
209
|
-
// (v1 SDK doesn't have question.reply, so we call it directly)
|
|
210
|
-
const port = getOpencodeServerPort(context.directory)
|
|
211
|
-
if (!port) {
|
|
203
|
+
const clientV2 = getOpencodeClientV2(context.directory)
|
|
204
|
+
if (!clientV2) {
|
|
212
205
|
throw new Error('OpenCode server not found for directory')
|
|
213
206
|
}
|
|
214
207
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
headers: { 'Content-Type': 'application/json' },
|
|
220
|
-
body: JSON.stringify({ answers: answersPayload }),
|
|
221
|
-
}
|
|
222
|
-
)
|
|
208
|
+
// Build answers array: each element is an array of selected labels for that question
|
|
209
|
+
const answers = context.questions.map((_, i) => {
|
|
210
|
+
return context.answers[i] || []
|
|
211
|
+
})
|
|
223
212
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
213
|
+
await clientV2.question.reply({
|
|
214
|
+
requestID: context.requestId,
|
|
215
|
+
answers,
|
|
216
|
+
})
|
|
228
217
|
|
|
229
218
|
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
|
|
230
219
|
} catch (error) {
|
|
@@ -275,3 +264,49 @@ export function parseAskUserQuestionTool(part: {
|
|
|
275
264
|
|
|
276
265
|
return input
|
|
277
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
270
|
+
* Sends cancellation response to OpenCode so the session can continue.
|
|
271
|
+
*/
|
|
272
|
+
export async function cancelPendingQuestion(threadId: string): Promise<boolean> {
|
|
273
|
+
// Find pending question for this thread
|
|
274
|
+
let contextHash: string | undefined
|
|
275
|
+
let context: PendingQuestionContext | undefined
|
|
276
|
+
for (const [hash, ctx] of pendingQuestionContexts) {
|
|
277
|
+
if (ctx.thread.id === threadId) {
|
|
278
|
+
contextHash = hash
|
|
279
|
+
context = ctx
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!contextHash || !context) {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const clientV2 = getOpencodeClientV2(context.directory)
|
|
290
|
+
if (!clientV2) {
|
|
291
|
+
throw new Error('OpenCode server not found for directory')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Preserve already-answered questions, mark unanswered as cancelled
|
|
295
|
+
const answers = context.questions.map((_, i) => {
|
|
296
|
+
return context.answers[i] || ['(cancelled - user sent new message)']
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
await clientV2.question.reply({
|
|
300
|
+
requestID: context.requestId,
|
|
301
|
+
answers,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
logger.log(`Cancelled question ${context.requestId} due to new user message`)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
logger.error('Failed to cancel question:', error)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Clean up regardless of whether the API call succeeded
|
|
310
|
+
pendingQuestionContexts.delete(contextHash)
|
|
311
|
+
return true
|
|
312
|
+
}
|
package/src/discord-bot.ts
CHANGED
|
@@ -180,7 +180,10 @@ export async function startDiscordBot({
|
|
|
180
180
|
)
|
|
181
181
|
|
|
182
182
|
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
183
|
-
await message.
|
|
183
|
+
await message.reply({
|
|
184
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
185
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
186
|
+
})
|
|
184
187
|
return
|
|
185
188
|
}
|
|
186
189
|
}
|
package/src/discord-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { Lexer } from 'marked'
|
|
12
12
|
import { extractTagsArrays } from './xml.js'
|
|
13
13
|
import { formatMarkdownTables } from './format-tables.js'
|
|
14
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
|
|
14
15
|
import { createLogger } from './logger.js'
|
|
15
16
|
|
|
16
17
|
const discordLogger = createLogger('DISCORD')
|
|
@@ -125,7 +126,8 @@ export function splitMarkdownForDiscord({
|
|
|
125
126
|
|
|
126
127
|
// calculate overhead for code block markers
|
|
127
128
|
const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
|
|
128
|
-
|
|
129
|
+
// ensure at least 10 chars available, even if maxLength is very small
|
|
130
|
+
const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50)
|
|
129
131
|
|
|
130
132
|
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
|
|
131
133
|
|
|
@@ -198,6 +200,7 @@ export async function sendThreadMessage(
|
|
|
198
200
|
const MAX_LENGTH = 2000
|
|
199
201
|
|
|
200
202
|
content = formatMarkdownTables(content)
|
|
203
|
+
content = unnestCodeBlocksFromLists(content)
|
|
201
204
|
content = escapeBackticksInCodeBlocks(content)
|
|
202
205
|
|
|
203
206
|
// If custom flags provided, send as single message (no chunking)
|
|
@@ -376,11 +376,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
|
|
|
376
376
|
\`\`\`
|
|
377
377
|
",
|
|
378
378
|
"\`\`\`js
|
|
379
|
-
|
|
380
|
-
\`\`\`
|
|
379
|
+
veryverylo\`\`\`
|
|
381
380
|
",
|
|
382
381
|
"\`\`\`js
|
|
383
|
-
|
|
382
|
+
nglinethat\`\`\`
|
|
383
|
+
",
|
|
384
|
+
"\`\`\`js
|
|
385
|
+
exceedsmax\`\`\`
|
|
386
|
+
",
|
|
387
|
+
"\`\`\`js
|
|
388
|
+
length
|
|
389
|
+
\`\`\`
|
|
390
|
+
",
|
|
391
|
+
"short
|
|
384
392
|
\`\`\`
|
|
385
393
|
",
|
|
386
394
|
]
|
package/src/session-handler.ts
CHANGED
|
@@ -8,12 +8,12 @@ import type { Message, ThreadChannel } from 'discord.js'
|
|
|
8
8
|
import prettyMilliseconds from 'pretty-ms'
|
|
9
9
|
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
|
|
10
10
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
|
|
11
|
-
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
|
|
11
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
|
|
12
12
|
import { formatPart } from './message-formatting.js'
|
|
13
13
|
import { getOpencodeSystemMessage } from './system-message.js'
|
|
14
14
|
import { createLogger } from './logger.js'
|
|
15
15
|
import { isAbortError } from './utils.js'
|
|
16
|
-
import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
|
|
16
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
|
|
17
17
|
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
|
|
18
18
|
|
|
19
19
|
const sessionLogger = createLogger('SESSION')
|
|
@@ -239,6 +239,13 @@ export async function handleOpencodeSession({
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Cancel any pending question tool if user sends a new message
|
|
243
|
+
const questionCancelled = await cancelPendingQuestion(thread.id)
|
|
244
|
+
if (questionCancelled) {
|
|
245
|
+
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
|
|
246
|
+
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
const abortController = new AbortController()
|
|
243
250
|
abortControllers.set(session.id, abortController)
|
|
244
251
|
|
|
@@ -399,7 +406,8 @@ export async function handleOpencodeSession({
|
|
|
399
406
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
400
407
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
401
408
|
lastDisplayedContextPercentage = thresholdCrossed
|
|
402
|
-
|
|
409
|
+
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
410
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
403
411
|
}
|
|
404
412
|
}
|
|
405
413
|
}
|
|
@@ -530,6 +538,14 @@ export async function handleOpencodeSession({
|
|
|
530
538
|
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
531
539
|
)
|
|
532
540
|
|
|
541
|
+
// Flush any pending text/reasoning parts before showing the dropdown
|
|
542
|
+
// This ensures text the LLM generated before the question tool is shown first
|
|
543
|
+
for (const p of currentParts) {
|
|
544
|
+
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
545
|
+
await sendPartMessage(p)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
533
549
|
await showAskUserQuestionDropdowns({
|
|
534
550
|
thread,
|
|
535
551
|
sessionId: session.id,
|
package/src/system-message.ts
CHANGED
|
@@ -38,17 +38,17 @@ IMPORTANT: After editing any files, you MUST execute the critique command using
|
|
|
38
38
|
|
|
39
39
|
Execute this after making changes:
|
|
40
40
|
|
|
41
|
-
bunx critique web
|
|
41
|
+
bunx critique web --title "Add user authentication flow"
|
|
42
42
|
|
|
43
43
|
If there are other unrelated changes in the working directory, filter to only show the files you edited:
|
|
44
44
|
|
|
45
|
-
bunx critique web -- path/to/file1.ts path/to/file2.ts
|
|
45
|
+
bunx critique web --title "Fix database connection retry" -- path/to/file1.ts path/to/file2.ts
|
|
46
46
|
|
|
47
47
|
You can also show latest commit changes using:
|
|
48
48
|
|
|
49
|
-
bunx critique web HEAD
|
|
49
|
+
bunx critique web --title "Refactor API endpoints" HEAD
|
|
50
50
|
|
|
51
|
-
bunx critique web HEAD~1 to get the one before last
|
|
51
|
+
bunx critique web --title "Update dependencies" HEAD~1 to get the one before last
|
|
52
52
|
|
|
53
53
|
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
54
54
|
|