kimaki 0.4.25 → 0.4.26
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/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +59 -7
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +20 -0
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +131 -62
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +74 -8
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +25 -0
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +180 -90
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
|
@@ -12,14 +12,19 @@ import path from 'node:path'
|
|
|
12
12
|
import { getDatabase } from './database.js'
|
|
13
13
|
import { extractTagsArrays } from './xml.js'
|
|
14
14
|
|
|
15
|
-
export async function ensureKimakiCategory(
|
|
15
|
+
export async function ensureKimakiCategory(
|
|
16
|
+
guild: Guild,
|
|
17
|
+
botName?: string,
|
|
18
|
+
): Promise<CategoryChannel> {
|
|
19
|
+
const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
|
|
20
|
+
|
|
16
21
|
const existingCategory = guild.channels.cache.find(
|
|
17
22
|
(channel): channel is CategoryChannel => {
|
|
18
23
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
19
24
|
return false
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
return channel.name.toLowerCase() ===
|
|
27
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
23
28
|
},
|
|
24
29
|
)
|
|
25
30
|
|
|
@@ -28,19 +33,24 @@ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChanne
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
return guild.channels.create({
|
|
31
|
-
name:
|
|
36
|
+
name: categoryName,
|
|
32
37
|
type: ChannelType.GuildCategory,
|
|
33
38
|
})
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
export async function ensureKimakiAudioCategory(
|
|
41
|
+
export async function ensureKimakiAudioCategory(
|
|
42
|
+
guild: Guild,
|
|
43
|
+
botName?: string,
|
|
44
|
+
): Promise<CategoryChannel> {
|
|
45
|
+
const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
|
|
46
|
+
|
|
37
47
|
const existingCategory = guild.channels.cache.find(
|
|
38
48
|
(channel): channel is CategoryChannel => {
|
|
39
49
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
40
50
|
return false
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
return channel.name.toLowerCase() ===
|
|
53
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
44
54
|
},
|
|
45
55
|
)
|
|
46
56
|
|
|
@@ -49,7 +59,7 @@ export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryC
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
return guild.channels.create({
|
|
52
|
-
name:
|
|
62
|
+
name: categoryName,
|
|
53
63
|
type: ChannelType.GuildCategory,
|
|
54
64
|
})
|
|
55
65
|
}
|
|
@@ -58,10 +68,12 @@ export async function createProjectChannels({
|
|
|
58
68
|
guild,
|
|
59
69
|
projectDirectory,
|
|
60
70
|
appId,
|
|
71
|
+
botName,
|
|
61
72
|
}: {
|
|
62
73
|
guild: Guild
|
|
63
74
|
projectDirectory: string
|
|
64
75
|
appId: string
|
|
76
|
+
botName?: string
|
|
65
77
|
}): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
|
|
66
78
|
const baseName = path.basename(projectDirectory)
|
|
67
79
|
const channelName = `${baseName}`
|
|
@@ -69,8 +81,8 @@ export async function createProjectChannels({
|
|
|
69
81
|
.replace(/[^a-z0-9-]/g, '-')
|
|
70
82
|
.slice(0, 100)
|
|
71
83
|
|
|
72
|
-
const kimakiCategory = await ensureKimakiCategory(guild)
|
|
73
|
-
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
|
|
84
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName)
|
|
85
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
|
|
74
86
|
|
|
75
87
|
const textChannel = await guild.channels.create({
|
|
76
88
|
name: channelName,
|
package/src/cli.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
createProjectChannels,
|
|
28
28
|
type ChannelWithTags,
|
|
29
29
|
} from './discord-bot.js'
|
|
30
|
-
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
30
|
+
import type { OpencodeClient, Command as OpencodeCommand } from '@opencode-ai/sdk'
|
|
31
31
|
import {
|
|
32
32
|
Events,
|
|
33
33
|
ChannelType,
|
|
@@ -82,13 +82,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
82
82
|
// Filter out our own PID and take the first (oldest)
|
|
83
83
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
84
84
|
if (targetPid) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
const pid = parseInt(targetPid, 10)
|
|
86
|
+
cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`)
|
|
87
|
+
process.kill(pid, 'SIGKILL')
|
|
87
88
|
return true
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
} catch {
|
|
91
|
-
|
|
91
|
+
} catch (e) {
|
|
92
|
+
cliLogger.debug(`Failed to kill process on port ${port}:`, e)
|
|
92
93
|
}
|
|
93
94
|
return false
|
|
94
95
|
}
|
|
@@ -105,7 +106,7 @@ async function checkSingleInstance(): Promise<void> {
|
|
|
105
106
|
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
106
107
|
}
|
|
107
108
|
} catch {
|
|
108
|
-
|
|
109
|
+
cliLogger.debug('No other kimaki instance detected on lock port')
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
@@ -152,7 +153,10 @@ type CliOptions = {
|
|
|
152
153
|
addChannels?: boolean
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
// Commands to skip when registering user commands (reserved names)
|
|
157
|
+
const SKIP_USER_COMMANDS = ['init']
|
|
158
|
+
|
|
159
|
+
async function registerCommands(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
|
|
156
160
|
const commands = [
|
|
157
161
|
new SlashCommandBuilder()
|
|
158
162
|
.setName('resume')
|
|
@@ -231,6 +235,10 @@ async function registerCommands(token: string, appId: string) {
|
|
|
231
235
|
.setName('abort')
|
|
232
236
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
233
237
|
.toJSON(),
|
|
238
|
+
new SlashCommandBuilder()
|
|
239
|
+
.setName('stop')
|
|
240
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
241
|
+
.toJSON(),
|
|
234
242
|
new SlashCommandBuilder()
|
|
235
243
|
.setName('share')
|
|
236
244
|
.setDescription('Share the current session as a public URL')
|
|
@@ -243,6 +251,10 @@ async function registerCommands(token: string, appId: string) {
|
|
|
243
251
|
.setName('model')
|
|
244
252
|
.setDescription('Set the preferred model for this channel or session')
|
|
245
253
|
.toJSON(),
|
|
254
|
+
new SlashCommandBuilder()
|
|
255
|
+
.setName('agent')
|
|
256
|
+
.setDescription('Set the preferred agent for this channel or session')
|
|
257
|
+
.toJSON(),
|
|
246
258
|
new SlashCommandBuilder()
|
|
247
259
|
.setName('queue')
|
|
248
260
|
.setDescription('Queue a message to be sent after the current response finishes')
|
|
@@ -269,6 +281,30 @@ async function registerCommands(token: string, appId: string) {
|
|
|
269
281
|
.toJSON(),
|
|
270
282
|
]
|
|
271
283
|
|
|
284
|
+
// Add user-defined commands with -cmd suffix
|
|
285
|
+
for (const cmd of userCommands) {
|
|
286
|
+
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const commandName = `${cmd.name}-cmd`
|
|
291
|
+
const description = cmd.description || `Run /${cmd.name} command`
|
|
292
|
+
|
|
293
|
+
commands.push(
|
|
294
|
+
new SlashCommandBuilder()
|
|
295
|
+
.setName(commandName)
|
|
296
|
+
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
297
|
+
.addStringOption((option) => {
|
|
298
|
+
option
|
|
299
|
+
.setName('arguments')
|
|
300
|
+
.setDescription('Arguments to pass to the command')
|
|
301
|
+
.setRequired(false)
|
|
302
|
+
return option
|
|
303
|
+
})
|
|
304
|
+
.toJSON(),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
272
308
|
const rest = new REST().setToken(token)
|
|
273
309
|
|
|
274
310
|
try {
|
|
@@ -686,6 +722,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
686
722
|
guild: targetGuild,
|
|
687
723
|
projectDirectory: project.worktree,
|
|
688
724
|
appId,
|
|
725
|
+
botName: discordClient.user?.username,
|
|
689
726
|
})
|
|
690
727
|
|
|
691
728
|
createdChannels.push({
|
|
@@ -709,8 +746,37 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
709
746
|
}
|
|
710
747
|
}
|
|
711
748
|
|
|
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
|
+
// Log available user commands
|
|
763
|
+
const registrableCommands = allUserCommands.filter(
|
|
764
|
+
(cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
if (registrableCommands.length > 0) {
|
|
768
|
+
const commandList = registrableCommands
|
|
769
|
+
.map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
|
|
770
|
+
.join('\n')
|
|
771
|
+
|
|
772
|
+
note(
|
|
773
|
+
`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
|
|
774
|
+
'OpenCode Commands',
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
712
778
|
cliLogger.log('Registering slash commands asynchronously...')
|
|
713
|
-
void registerCommands(token, appId)
|
|
779
|
+
void registerCommands(token, appId, allUserCommands)
|
|
714
780
|
.then(() => {
|
|
715
781
|
cliLogger.log('Slash commands registered!')
|
|
716
782
|
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// /agent command - Set the preferred agent for this channel or session.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChatInputCommandInteraction,
|
|
5
|
+
StringSelectMenuInteraction,
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
ActionRowBuilder,
|
|
8
|
+
ChannelType,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
type TextChannel,
|
|
11
|
+
} from 'discord.js'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
|
|
14
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
|
+
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
|
|
18
|
+
const agentLogger = createLogger('AGENT')
|
|
19
|
+
|
|
20
|
+
const pendingAgentContexts = new Map<string, {
|
|
21
|
+
dir: string
|
|
22
|
+
channelId: string
|
|
23
|
+
sessionId?: string
|
|
24
|
+
isThread: boolean
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
export async function handleAgentCommand({
|
|
28
|
+
interaction,
|
|
29
|
+
appId,
|
|
30
|
+
}: {
|
|
31
|
+
interaction: ChatInputCommandInteraction
|
|
32
|
+
appId: string
|
|
33
|
+
}): Promise<void> {
|
|
34
|
+
await interaction.deferReply({ ephemeral: true })
|
|
35
|
+
|
|
36
|
+
runModelMigrations()
|
|
37
|
+
|
|
38
|
+
const channel = interaction.channel
|
|
39
|
+
|
|
40
|
+
if (!channel) {
|
|
41
|
+
await interaction.editReply({ content: 'This command can only be used in a channel' })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isThread = [
|
|
46
|
+
ChannelType.PublicThread,
|
|
47
|
+
ChannelType.PrivateThread,
|
|
48
|
+
ChannelType.AnnouncementThread,
|
|
49
|
+
].includes(channel.type)
|
|
50
|
+
|
|
51
|
+
let projectDirectory: string | undefined
|
|
52
|
+
let channelAppId: string | undefined
|
|
53
|
+
let targetChannelId: string
|
|
54
|
+
let sessionId: string | undefined
|
|
55
|
+
|
|
56
|
+
if (isThread) {
|
|
57
|
+
const thread = channel as ThreadChannel
|
|
58
|
+
const textChannel = await resolveTextChannel(thread)
|
|
59
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
60
|
+
projectDirectory = metadata.projectDirectory
|
|
61
|
+
channelAppId = metadata.channelAppId
|
|
62
|
+
targetChannelId = textChannel?.id || channel.id
|
|
63
|
+
|
|
64
|
+
const row = getDatabase()
|
|
65
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
66
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
67
|
+
sessionId = row?.session_id
|
|
68
|
+
} else if (channel.type === ChannelType.GuildText) {
|
|
69
|
+
const textChannel = channel as TextChannel
|
|
70
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
71
|
+
projectDirectory = metadata.projectDirectory
|
|
72
|
+
channelAppId = metadata.channelAppId
|
|
73
|
+
targetChannelId = channel.id
|
|
74
|
+
} else {
|
|
75
|
+
await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (channelAppId && channelAppId !== appId) {
|
|
80
|
+
await interaction.editReply({ content: 'This channel is not configured for this bot' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!projectDirectory) {
|
|
85
|
+
await interaction.editReply({ content: 'This channel is not configured with a project directory' })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
91
|
+
|
|
92
|
+
const agentsResponse = await getClient().app.agents({
|
|
93
|
+
query: { directory: projectDirectory },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
97
|
+
await interaction.editReply({ content: 'No agents available' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agents = agentsResponse.data
|
|
102
|
+
.filter((a) => a.mode === 'primary' || a.mode === 'all')
|
|
103
|
+
.slice(0, 25)
|
|
104
|
+
|
|
105
|
+
if (agents.length === 0) {
|
|
106
|
+
await interaction.editReply({ content: 'No primary agents available' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
111
|
+
pendingAgentContexts.set(contextHash, {
|
|
112
|
+
dir: projectDirectory,
|
|
113
|
+
channelId: targetChannelId,
|
|
114
|
+
sessionId,
|
|
115
|
+
isThread,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const options = agents.map((agent) => ({
|
|
119
|
+
label: agent.name.slice(0, 100),
|
|
120
|
+
value: agent.name,
|
|
121
|
+
description: (agent.description || `${agent.mode} agent`).slice(0, 100),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
125
|
+
.setCustomId(`agent_select:${contextHash}`)
|
|
126
|
+
.setPlaceholder('Select an agent')
|
|
127
|
+
.addOptions(options)
|
|
128
|
+
|
|
129
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
130
|
+
|
|
131
|
+
await interaction.editReply({
|
|
132
|
+
content: '**Set Agent Preference**\nSelect an agent:',
|
|
133
|
+
components: [actionRow],
|
|
134
|
+
})
|
|
135
|
+
} catch (error) {
|
|
136
|
+
agentLogger.error('Error loading agents:', error)
|
|
137
|
+
await interaction.editReply({
|
|
138
|
+
content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function handleAgentSelectMenu(
|
|
144
|
+
interaction: StringSelectMenuInteraction
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const customId = interaction.customId
|
|
147
|
+
|
|
148
|
+
if (!customId.startsWith('agent_select:')) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await interaction.deferUpdate()
|
|
153
|
+
|
|
154
|
+
const contextHash = customId.replace('agent_select:', '')
|
|
155
|
+
const context = pendingAgentContexts.get(contextHash)
|
|
156
|
+
|
|
157
|
+
if (!context) {
|
|
158
|
+
await interaction.editReply({
|
|
159
|
+
content: 'Selection expired. Please run /agent again.',
|
|
160
|
+
components: [],
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const selectedAgent = interaction.values[0]
|
|
166
|
+
if (!selectedAgent) {
|
|
167
|
+
await interaction.editReply({
|
|
168
|
+
content: 'No agent selected',
|
|
169
|
+
components: [],
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (context.isThread && context.sessionId) {
|
|
176
|
+
setSessionAgent(context.sessionId, selectedAgent)
|
|
177
|
+
agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
|
|
178
|
+
|
|
179
|
+
await interaction.editReply({
|
|
180
|
+
content: `Agent preference set for this session: **${selectedAgent}**`,
|
|
181
|
+
components: [],
|
|
182
|
+
})
|
|
183
|
+
} else {
|
|
184
|
+
setChannelAgent(context.channelId, selectedAgent)
|
|
185
|
+
agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
|
|
186
|
+
|
|
187
|
+
await interaction.editReply({
|
|
188
|
+
content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
|
|
189
|
+
components: [],
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pendingAgentContexts.delete(contextHash)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
agentLogger.error('Error saving agent preference:', error)
|
|
196
|
+
await interaction.editReply({
|
|
197
|
+
content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
198
|
+
components: [],
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|