kimaki 0.1.0 → 0.1.3

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/bin.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const __filename = fileURLToPath(import.meta.url)
7
+ const __dirname = dirname(__filename)
8
+
9
+ const NODE_PATH = process.execPath
10
+ const CLI_PATH = join(__dirname, 'dist', 'cli.js')
11
+
12
+ let lastStart = 0
13
+
14
+ async function sleep(ms) {
15
+ return new Promise(resolve => setTimeout(resolve, ms))
16
+ }
17
+
18
+ async function run() {
19
+ while (true) {
20
+ const now = Date.now()
21
+ const elapsed = now - lastStart
22
+ if (elapsed < 5000) {
23
+ await sleep(5000 - elapsed)
24
+ }
25
+ lastStart = Date.now()
26
+
27
+ try {
28
+ const code = await new Promise((resolve) => {
29
+ const child = spawn(NODE_PATH, [CLI_PATH, ...process.argv.slice(2)], {
30
+ stdio: 'inherit'
31
+ })
32
+
33
+ child.on('exit', (code, signal) => {
34
+ if (signal) {
35
+ // Map signals to exit codes similar to bash
36
+ if (signal === 'SIGINT') resolve(130)
37
+ else if (signal === 'SIGTERM') resolve(143)
38
+ else resolve(1)
39
+ } else {
40
+ resolve(code || 0)
41
+ }
42
+ })
43
+
44
+ child.on('error', (err) => {
45
+ console.error('Failed to start process:', err)
46
+ resolve(1)
47
+ })
48
+ })
49
+
50
+ // Exit cleanly if the app ended OK or via SIGINT/SIGTERM
51
+ if (code === 0 || code === 130 || code === 143 || code === 64) {
52
+ process.exit(code)
53
+ }
54
+ // otherwise loop; the 5s throttle above will apply
55
+ } catch (err) {
56
+ console.error('Unexpected error:', err)
57
+ // Continue looping after error
58
+ }
59
+ }
60
+ }
61
+
62
+ run().catch(err => {
63
+ console.error('Fatal error:', err)
64
+ process.exit(1)
65
+ })
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { cac } from 'cac';
3
- import { intro, outro, text, password, note, cancel, isCancel, log, multiselect, spinner, } from '@clack/prompts';
4
- import { generateBotInstallUrl } from './utils.js';
3
+ import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
4
+ import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
5
5
  import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, } from './discordBot.js';
6
6
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
7
7
  import path from 'node:path';
@@ -74,7 +74,6 @@ async function ensureKimakiCategory(guild) {
74
74
  }
75
75
  async function run({ restart, addChannels }) {
76
76
  const forceSetup = Boolean(restart);
77
- const shouldAddChannels = Boolean(addChannels);
78
77
  intro('🤖 Discord Bot Setup');
79
78
  const db = getDatabase();
80
79
  let appId;
@@ -82,6 +81,7 @@ async function run({ restart, addChannels }) {
82
81
  const existingBot = db
83
82
  .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
84
83
  .get();
84
+ const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels);
85
85
  if (existingBot && !forceSetup) {
86
86
  appId = existingBot.app_id;
87
87
  token = existingBot.token;
@@ -112,8 +112,22 @@ async function run({ restart, addChannels }) {
112
112
  }
113
113
  appId = appIdInput;
114
114
  note('1. Go to the "Bot" section in the left sidebar\n' +
115
- '2. Click "Reset Token" to generate a new bot token\n' +
116
- "3. Copy the token (you won't be able to see it again!)", 'Step 2: Get Bot Token');
115
+ '2. Scroll down to "Privileged Gateway Intents"\n' +
116
+ '3. Enable these intents by toggling them ON:\n' +
117
+ ' • SERVER MEMBERS INTENT\n' +
118
+ ' • MESSAGE CONTENT INTENT\n' +
119
+ '4. Click "Save Changes" at the bottom', 'Step 2: Enable Required Intents');
120
+ const intentsConfirmed = await text({
121
+ message: 'Press Enter after enabling both intents:',
122
+ placeholder: 'Enter',
123
+ });
124
+ if (isCancel(intentsConfirmed)) {
125
+ cancel('Setup cancelled');
126
+ process.exit(0);
127
+ }
128
+ note('1. Still in the "Bot" section\n' +
129
+ '2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
130
+ "3. Copy the token (you won't be able to see it again!)", 'Step 3: Get Bot Token');
117
131
  const tokenInput = await password({
118
132
  message: 'Enter your Discord Bot Token (will be hidden):',
119
133
  validate(value) {
@@ -128,15 +142,10 @@ async function run({ restart, addChannels }) {
128
142
  process.exit(0);
129
143
  }
130
144
  token = tokenInput;
131
- db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
132
- note('Token saved to database', 'Credentials Stored');
133
- note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 3: Install Bot to Server');
145
+ note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
134
146
  const installed = await text({
135
147
  message: 'Press Enter AFTER you have installed the bot in your server:',
136
- placeholder: 'Press Enter to continue',
137
- validate() {
138
- return undefined;
139
- },
148
+ placeholder: 'Enter',
140
149
  });
141
150
  if (isCancel(installed)) {
142
151
  cancel('Setup cancelled');
@@ -172,6 +181,7 @@ async function run({ restart, addChannels }) {
172
181
  cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
173
182
  process.exit(EXIT_NO_RESTART);
174
183
  }
184
+ db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
175
185
  for (const { guild, channels } of kimakiChannels) {
176
186
  for (const channel of channels) {
177
187
  if (channel.kimakiDirectory) {
@@ -197,22 +207,13 @@ async function run({ restart, addChannels }) {
197
207
  note(channelList, 'Existing Kimaki Channels');
198
208
  }
199
209
  s.start('Starting OpenCode server...');
200
- let client;
201
- try {
202
- const currentDir = process.cwd();
203
- client = await initializeOpencodeForDirectory(currentDir);
204
- s.stop('OpenCode server started!');
205
- }
206
- catch (error) {
207
- s.stop('Failed to start OpenCode');
208
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
209
- discordClient.destroy();
210
- process.exit(EXIT_NO_RESTART);
211
- }
210
+ const currentDir = process.cwd();
211
+ let getClient = await initializeOpencodeForDirectory(currentDir);
212
+ s.stop('OpenCode server started!');
212
213
  s.start('Fetching OpenCode projects...');
213
214
  let projects = [];
214
215
  try {
215
- const projectsResponse = await client.project.list();
216
+ const projectsResponse = await getClient().project.list();
216
217
  if (!projectsResponse.data) {
217
218
  throw new Error('Failed to fetch projects');
218
219
  }
@@ -225,12 +226,16 @@ async function run({ restart, addChannels }) {
225
226
  discordClient.destroy();
226
227
  process.exit(EXIT_NO_RESTART);
227
228
  }
228
- const existingDirs = kimakiChannels.flatMap(({ channels }) => channels.map((ch) => ch.kimakiDirectory).filter(Boolean));
229
- const availableProjects = projects.filter((project) => !existingDirs.includes(project.worktree));
229
+ const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
230
+ .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
231
+ .map((ch) => ch.kimakiDirectory)
232
+ .filter(Boolean));
233
+ const availableProjects = deduplicateByKey(projects.filter((project) => !existingDirs.includes(project.worktree)), (x) => x.worktree);
230
234
  if (availableProjects.length === 0) {
231
235
  note('All OpenCode projects already have Discord channels', 'No New Projects');
232
236
  }
233
- if (shouldAddChannels && availableProjects.length > 0) {
237
+ if ((!existingDirs?.length && availableProjects.length > 0) ||
238
+ shouldAddChannels) {
234
239
  const selectedProjects = await multiselect({
235
240
  message: 'Select projects to create Discord channels for:',
236
241
  options: availableProjects.map((project) => ({
@@ -271,7 +276,7 @@ async function run({ restart, addChannels }) {
271
276
  if (!project)
272
277
  continue;
273
278
  const baseName = path.basename(project.worktree);
274
- const channelName = `kimaki-${baseName}`
279
+ const channelName = `${baseName}`
275
280
  .toLowerCase()
276
281
  .replace(/[^a-z0-9-]/g, '-')
277
282
  .slice(0, 100);
@@ -14,7 +14,7 @@ import { PassThrough, Transform } from 'node:stream';
14
14
  import * as prism from 'prism-media';
15
15
  import dedent from 'string-dedent';
16
16
  import { transcribeAudio } from './voice.js';
17
- import { extractTagsArrays } from './xml.js';
17
+ import { extractTagsArrays, extractNonXmlContent } from './xml.js';
18
18
  import prettyMilliseconds from 'pretty-ms';
19
19
  import { createLogger } from './logger.js';
20
20
  const discordLogger = createLogger('DISCORD');
@@ -28,6 +28,8 @@ const opencodeServers = new Map();
28
28
  const abortControllers = new Map();
29
29
  // Map of guild ID to voice connection and GenAI worker
30
30
  const voiceConnections = new Map();
31
+ // Map of directory to retry count for server restarts
32
+ const serverRetryCount = new Map();
31
33
  let db = null;
32
34
  function convertToMono16k(buffer) {
33
35
  // Parameters
@@ -225,14 +227,16 @@ async function setupVoiceHandling({ connection, guildId, channelId, }) {
225
227
  .on('data', (frame) => {
226
228
  // Check if a newer speaking session has started
227
229
  if (currentSessionCount !== speakingSessionCount) {
228
- voiceLogger.log(`Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`);
230
+ // voiceLogger.log(
231
+ // `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
232
+ // )
229
233
  return;
230
234
  }
231
235
  if (!voiceData.genAiWorker) {
232
236
  voiceLogger.warn(`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`);
233
237
  return;
234
238
  }
235
- voiceLogger.debug('User audio chunk length', frame.length);
239
+ // voiceLogger.debug('User audio chunk length', frame.length)
236
240
  // Write to PCM file if stream exists
237
241
  voiceData.userAudioStream?.write(frame);
238
242
  // stream incrementally — low latency
@@ -551,29 +555,19 @@ function getKimakiMetadata(textChannel) {
551
555
  const channelAppId = extracted['kimaki.app']?.[0]?.trim();
552
556
  return { projectDirectory, channelAppId };
553
557
  }
554
- function getFileAutocompleteState(value) {
555
- const input = value ?? '';
556
- const match = input.match(/([^,\s]*)$/);
557
- const token = match ? match[1] || '' : '';
558
- const prefix = input.slice(0, input.length - token.length);
559
- const parts = input
560
- .split(/[\s,]+/)
561
- .map((part) => part.trim())
562
- .filter(Boolean);
563
- const selected = new Set(parts);
564
- const trimmedToken = token.trim();
565
- if (trimmedToken) {
566
- selected.delete(trimmedToken);
567
- }
568
- return { prefix, token, selected };
569
- }
570
558
  export async function initializeOpencodeForDirectory(directory) {
571
559
  // console.log(`[OPENCODE] Initializing for directory: ${directory}`)
572
560
  // Check if we already have a server for this directory
573
561
  const existing = opencodeServers.get(directory);
574
562
  if (existing && !existing.process.killed) {
575
563
  opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
576
- return existing.client;
564
+ return () => {
565
+ const entry = opencodeServers.get(directory);
566
+ if (!entry?.client) {
567
+ throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
568
+ }
569
+ return entry.client;
570
+ };
577
571
  }
578
572
  const port = await getOpenPort();
579
573
  // console.log(
@@ -589,17 +583,34 @@ export async function initializeOpencodeForDirectory(directory) {
589
583
  },
590
584
  });
591
585
  serverProcess.stdout?.on('data', (data) => {
592
- opencodeLogger.log(`Port ${port}: ${data.toString().trim()}`);
586
+ opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
593
587
  });
594
588
  serverProcess.stderr?.on('data', (data) => {
595
- opencodeLogger.error(`Port error: ${data.toString().trim()}`);
589
+ opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
596
590
  });
597
591
  serverProcess.on('error', (error) => {
598
- opencodeLogger.error(`Failed to start server on port :`, error);
592
+ opencodeLogger.error(`Failed to start server on port :`, port, error);
599
593
  });
600
594
  serverProcess.on('exit', (code) => {
601
- opencodeLogger.log(`Server on port exited with code:`, code);
595
+ opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
602
596
  opencodeServers.delete(directory);
597
+ if (code !== 0) {
598
+ const retryCount = serverRetryCount.get(directory) || 0;
599
+ if (retryCount < 5) {
600
+ serverRetryCount.set(directory, retryCount + 1);
601
+ opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
602
+ initializeOpencodeForDirectory(directory).catch((e) => {
603
+ opencodeLogger.error(`Failed to restart opencode server:`, e);
604
+ });
605
+ }
606
+ else {
607
+ opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
608
+ }
609
+ }
610
+ else {
611
+ // Reset retry count on clean exit
612
+ serverRetryCount.delete(directory);
613
+ }
603
614
  });
604
615
  await waitForServer(port);
605
616
  const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
@@ -608,7 +619,13 @@ export async function initializeOpencodeForDirectory(directory) {
608
619
  client,
609
620
  port,
610
621
  });
611
- return client;
622
+ return () => {
623
+ const entry = opencodeServers.get(directory);
624
+ if (!entry?.client) {
625
+ throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
626
+ }
627
+ return entry.client;
628
+ };
612
629
  }
613
630
  function formatPart(part) {
614
631
  switch (part.type) {
@@ -746,7 +763,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
746
763
  const directory = projectDirectory || process.cwd();
747
764
  sessionLogger.log(`Using directory: ${directory}`);
748
765
  // Note: We'll cancel the existing request after we have the session ID
749
- const client = await initializeOpencodeForDirectory(directory);
766
+ const getClient = await initializeOpencodeForDirectory(directory);
750
767
  // Get session ID from database
751
768
  const row = getDatabase()
752
769
  .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
@@ -756,7 +773,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
756
773
  if (sessionId) {
757
774
  sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
758
775
  try {
759
- const sessionResponse = await client.session.get({
776
+ const sessionResponse = await getClient().session.get({
760
777
  path: { id: sessionId },
761
778
  });
762
779
  session = sessionResponse.data;
@@ -768,7 +785,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
768
785
  }
769
786
  if (!session) {
770
787
  voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
771
- const sessionResponse = await client.session.create({
788
+ const sessionResponse = await getClient().session.create({
772
789
  body: { title: prompt.slice(0, 80) },
773
790
  });
774
791
  session = sessionResponse.data;
@@ -794,7 +811,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
794
811
  const abortController = new AbortController();
795
812
  // Store this controller for this session
796
813
  abortControllers.set(session.id, abortController);
797
- const eventsResult = await client.event.subscribe({
814
+ const eventsResult = await getClient().event.subscribe({
798
815
  signal: abortController.signal,
799
816
  });
800
817
  const events = eventsResult.stream;
@@ -1034,7 +1051,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1034
1051
  voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1035
1052
  // Start the event handler
1036
1053
  const eventHandlerPromise = eventHandler();
1037
- const response = await client.session.prompt({
1054
+ const response = await getClient().session.prompt({
1038
1055
  path: { id: session.id },
1039
1056
  body: {
1040
1057
  parts: [{ type: 'text', text: prompt }],
@@ -1322,9 +1339,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1322
1339
  }
1323
1340
  try {
1324
1341
  // Get OpenCode client for this directory
1325
- const client = await initializeOpencodeForDirectory(projectDirectory);
1342
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1326
1343
  // List sessions
1327
- const sessionsResponse = await client.session.list();
1344
+ const sessionsResponse = await getClient().session.list();
1328
1345
  if (!sessionsResponse.data) {
1329
1346
  await interaction.respond([]);
1330
1347
  return;
@@ -1385,9 +1402,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1385
1402
  }
1386
1403
  try {
1387
1404
  // Initialize OpenCode client for the directory
1388
- const client = await initializeOpencodeForDirectory(projectDirectory);
1405
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1389
1406
  // Get session title
1390
- const sessionResponse = await client.session.get({
1407
+ const sessionResponse = await getClient().session.get({
1391
1408
  path: { id: sessionId },
1392
1409
  });
1393
1410
  if (!sessionResponse.data) {
@@ -1407,7 +1424,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1407
1424
  .run(thread.id, sessionId);
1408
1425
  voiceLogger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`);
1409
1426
  // Fetch all messages for the session
1410
- const messagesResponse = await client.session.messages({
1427
+ const messagesResponse = await getClient().session.messages({
1411
1428
  path: { id: sessionId },
1412
1429
  });
1413
1430
  if (!messagesResponse.data) {
@@ -1423,10 +1440,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1423
1440
  if (message.info.role === 'user') {
1424
1441
  // Render user messages
1425
1442
  const userParts = message.parts.filter((p) => p.type === 'text');
1426
- const userText = userParts
1427
- .map((p) => (typeof p.text === 'string' ? p.text : ''))
1428
- .filter((t) => t.trim())
1429
- .join('\n\n');
1443
+ const userTexts = userParts
1444
+ .map((p) => {
1445
+ if (typeof p.text === 'string') {
1446
+ return extractNonXmlContent(p.text);
1447
+ }
1448
+ return '';
1449
+ })
1450
+ .filter((t) => t.trim());
1451
+ const userText = userTexts.join('\n\n');
1430
1452
  if (userText) {
1431
1453
  // Escape backticks in user messages to prevent formatting issues
1432
1454
  const escapedText = escapeDiscordFormatting(userText);
package/dist/markdown.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { format } from 'date-fns';
2
2
  import * as yaml from 'js-yaml';
3
+ import { extractNonXmlContent } from './xml.js';
3
4
  export class ShareMarkdown {
4
5
  client;
5
6
  constructor(client) {
@@ -73,8 +74,11 @@ export class ShareMarkdown {
73
74
  lines.push('');
74
75
  for (const part of parts) {
75
76
  if (part.type === 'text' && part.text) {
76
- lines.push(part.text);
77
- lines.push('');
77
+ const cleanedText = extractNonXmlContent(part.text);
78
+ if (cleanedText.trim()) {
79
+ lines.push(cleanedText);
80
+ lines.push('');
81
+ }
78
82
  }
79
83
  else if (part.type === 'file') {
80
84
  lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`);
package/dist/tools.js CHANGED
@@ -10,13 +10,14 @@ import { ShareMarkdown } from './markdown.js';
10
10
  import pc from 'picocolors';
11
11
  import { initializeOpencodeForDirectory } from './discordBot.js';
12
12
  export async function getTools({ onMessageCompleted, directory, }) {
13
- const client = await initializeOpencodeForDirectory(directory);
13
+ const getClient = await initializeOpencodeForDirectory(directory);
14
+ const client = getClient();
14
15
  const markdownRenderer = new ShareMarkdown(client);
15
16
  const providersResponse = await client.config.providers({});
16
17
  const providers = providersResponse.data?.providers || [];
17
18
  // Helper: get last assistant model for a session (non-summary)
18
19
  const getSessionModel = async (sessionId) => {
19
- const res = await client.session.messages({ path: { id: sessionId } });
20
+ const res = await getClient().session.messages({ path: { id: sessionId } });
20
21
  const data = res.data;
21
22
  if (!data || data.length === 0)
22
23
  return undefined;
@@ -41,8 +42,8 @@ export async function getTools({ onMessageCompleted, directory, }) {
41
42
  execute: async ({ sessionId, message }) => {
42
43
  const sessionModel = await getSessionModel(sessionId);
43
44
  // do not await
44
- client.session
45
- .prompt({
45
+ getClient()
46
+ .session.prompt({
46
47
  path: { id: sessionId },
47
48
  body: {
48
49
  parts: [{ type: 'text', text: message }],
@@ -99,7 +100,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
99
100
  throw new Error(`message must be a non empty string`);
100
101
  }
101
102
  try {
102
- const session = await client.session.create({
103
+ const session = await getClient().session.create({
103
104
  body: {
104
105
  title: title || message.slice(0, 50),
105
106
  },
@@ -108,8 +109,8 @@ export async function getTools({ onMessageCompleted, directory, }) {
108
109
  throw new Error('Failed to create session');
109
110
  }
110
111
  // do not await
111
- client.session
112
- .prompt({
112
+ getClient()
113
+ .session.prompt({
113
114
  path: { id: session.data.id },
114
115
  body: {
115
116
  parts: [{ type: 'text', text: message }],
@@ -155,7 +156,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
155
156
  inputSchema: z.object({}),
156
157
  execute: async () => {
157
158
  toolsLogger.log(`Listing opencode sessions`);
158
- const sessions = await client.session.list();
159
+ const sessions = await getClient().session.list();
159
160
  if (!sessions.data) {
160
161
  return { success: false, error: 'No sessions found' };
161
162
  }
@@ -169,7 +170,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
169
170
  const status = await (async () => {
170
171
  if (session.revert)
171
172
  return 'error';
172
- const messagesResponse = await client.session.messages({
173
+ const messagesResponse = await getClient().session.messages({
173
174
  path: { id: session.id },
174
175
  });
175
176
  const messages = messagesResponse.data || [];
@@ -208,7 +209,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
208
209
  query: z.string().describe('The search query for files'),
209
210
  }),
210
211
  execute: async ({ folder, query }) => {
211
- const results = await client.find.files({
212
+ const results = await getClient().find.files({
212
213
  query: {
213
214
  query,
214
215
  directory: folder,
@@ -231,7 +232,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
231
232
  }),
232
233
  execute: async ({ sessionId, lastAssistantOnly = false }) => {
233
234
  if (lastAssistantOnly) {
234
- const messages = await client.session.messages({
235
+ const messages = await getClient().session.messages({
235
236
  path: { id: sessionId },
236
237
  });
237
238
  if (!messages.data) {
@@ -263,7 +264,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
263
264
  const markdown = await markdownRenderer.generate({
264
265
  sessionID: sessionId,
265
266
  });
266
- const messages = await client.session.messages({
267
+ const messages = await getClient().session.messages({
267
268
  path: { id: sessionId },
268
269
  });
269
270
  const lastMessage = messages.data?.[messages.data.length - 1];
@@ -288,7 +289,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
288
289
  }),
289
290
  execute: async ({ sessionId }) => {
290
291
  try {
291
- const result = await client.session.abort({
292
+ const result = await getClient().session.abort({
292
293
  path: { id: sessionId },
293
294
  });
294
295
  if (!result.data) {
@@ -316,7 +317,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
316
317
  inputSchema: z.object({}),
317
318
  execute: async () => {
318
319
  try {
319
- const providersResponse = await client.config.providers({});
320
+ const providersResponse = await getClient().config.providers({});
320
321
  const providers = providersResponse.data?.providers || [];
321
322
  const models = [];
322
323
  providers.forEach((provider) => {
package/dist/utils.js CHANGED
@@ -28,25 +28,14 @@ export function generateBotInstallUrl({ clientId, permissions = [
28
28
  }
29
29
  return url.toString();
30
30
  }
31
- function getRequiredBotPermissions() {
32
- return [
33
- PermissionsBitField.Flags.ViewChannel,
34
- PermissionsBitField.Flags.ManageChannels,
35
- PermissionsBitField.Flags.SendMessages,
36
- PermissionsBitField.Flags.SendMessagesInThreads,
37
- PermissionsBitField.Flags.CreatePublicThreads,
38
- PermissionsBitField.Flags.ManageThreads,
39
- PermissionsBitField.Flags.ReadMessageHistory,
40
- PermissionsBitField.Flags.AddReactions,
41
- PermissionsBitField.Flags.ManageMessages,
42
- PermissionsBitField.Flags.UseExternalEmojis,
43
- PermissionsBitField.Flags.AttachFiles,
44
- PermissionsBitField.Flags.Connect,
45
- PermissionsBitField.Flags.Speak,
46
- ];
47
- }
48
- function getPermissionNames() {
49
- const permissions = getRequiredBotPermissions();
50
- const permissionsBitField = new PermissionsBitField(permissions);
51
- return permissionsBitField.toArray();
31
+ export function deduplicateByKey(arr, keyFn) {
32
+ const seen = new Set();
33
+ return arr.filter(item => {
34
+ const key = keyFn(item);
35
+ if (seen.has(key)) {
36
+ return false;
37
+ }
38
+ seen.add(key);
39
+ return true;
40
+ });
52
41
  }
package/dist/xml.js CHANGED
@@ -83,3 +83,7 @@ export function extractTagsArrays({ xml, tags, }) {
83
83
  }
84
84
  return result;
85
85
  }
86
+ export function extractNonXmlContent(xml) {
87
+ const result = extractTagsArrays({ xml, tags: [] });
88
+ return result.others.join('\n');
89
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { extractNonXmlContent } from './xml.js';
3
+ describe('extractNonXmlContent', () => {
4
+ test('removes xml tags and returns only text content', () => {
5
+ const xml = 'Hello <tag>content</tag> world <nested><inner>deep</inner></nested> end';
6
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
7
+ "Hello
8
+ world
9
+ end"
10
+ `);
11
+ });
12
+ test('handles multiple text segments', () => {
13
+ const xml = 'Start <a>tag1</a> middle <b>tag2</b> finish';
14
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
15
+ "Start
16
+ middle
17
+ finish"
18
+ `);
19
+ });
20
+ test('handles only xml without text', () => {
21
+ const xml = '<root><child>content</child></root>';
22
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
23
+ });
24
+ test('handles only text without xml', () => {
25
+ const xml = 'Just plain text';
26
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`"Just plain text"`);
27
+ });
28
+ test('handles empty string', () => {
29
+ const xml = '';
30
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
31
+ });
32
+ });
package/package.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.1.0",
5
+ "version": "0.1.3",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
- "bin": "bin.sh",
7
+ "bin": "bin.js",
8
8
  "files": [
9
9
  "dist",
10
10
  "src",
package/src/cli.ts CHANGED
@@ -8,11 +8,12 @@ import {
8
8
  note,
9
9
  cancel,
10
10
  isCancel,
11
+ confirm,
11
12
  log,
12
13
  multiselect,
13
14
  spinner,
14
15
  } from '@clack/prompts'
15
- import { generateBotInstallUrl } from './utils.js'
16
+ import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
16
17
  import {
17
18
  getChannelsWithDescriptions,
18
19
  createDiscordClient,
@@ -138,7 +139,6 @@ async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
138
139
 
139
140
  async function run({ restart, addChannels }: CliOptions) {
140
141
  const forceSetup = Boolean(restart)
141
- const shouldAddChannels = Boolean(addChannels)
142
142
 
143
143
  intro('🤖 Discord Bot Setup')
144
144
 
@@ -152,6 +152,9 @@ async function run({ restart, addChannels }: CliOptions) {
152
152
  )
153
153
  .get() as { app_id: string; token: string } | undefined
154
154
 
155
+ const shouldAddChannels =
156
+ !existingBot?.token || forceSetup || Boolean(addChannels)
157
+
155
158
  if (existingBot && !forceSetup) {
156
159
  appId = existingBot.app_id
157
160
  token = existingBot.token
@@ -196,9 +199,29 @@ async function run({ restart, addChannels }: CliOptions) {
196
199
 
197
200
  note(
198
201
  '1. Go to the "Bot" section in the left sidebar\n' +
199
- '2. Click "Reset Token" to generate a new bot token\n' +
202
+ '2. Scroll down to "Privileged Gateway Intents"\n' +
203
+ '3. Enable these intents by toggling them ON:\n' +
204
+ ' • SERVER MEMBERS INTENT\n' +
205
+ ' • MESSAGE CONTENT INTENT\n' +
206
+ '4. Click "Save Changes" at the bottom',
207
+ 'Step 2: Enable Required Intents',
208
+ )
209
+
210
+ const intentsConfirmed = await text({
211
+ message: 'Press Enter after enabling both intents:',
212
+ placeholder: 'Enter',
213
+ })
214
+
215
+ if (isCancel(intentsConfirmed)) {
216
+ cancel('Setup cancelled')
217
+ process.exit(0)
218
+ }
219
+
220
+ note(
221
+ '1. Still in the "Bot" section\n' +
222
+ '2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
200
223
  "3. Copy the token (you won't be able to see it again!)",
201
- 'Step 2: Get Bot Token',
224
+ 'Step 3: Get Bot Token',
202
225
  )
203
226
 
204
227
  const tokenInput = await password({
@@ -215,23 +238,14 @@ async function run({ restart, addChannels }: CliOptions) {
215
238
  }
216
239
  token = tokenInput
217
240
 
218
- db.prepare(
219
- 'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
220
- ).run(appId, token)
221
-
222
- note('Token saved to database', 'Credentials Stored')
223
-
224
241
  note(
225
242
  `Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`,
226
- 'Step 3: Install Bot to Server',
243
+ 'Step 4: Install Bot to Server',
227
244
  )
228
245
 
229
246
  const installed = await text({
230
247
  message: 'Press Enter AFTER you have installed the bot in your server:',
231
- placeholder: 'Press Enter to continue',
232
- validate() {
233
- return undefined
234
- },
248
+ placeholder: 'Enter',
235
249
  })
236
250
 
237
251
  if (isCancel(installed)) {
@@ -282,6 +296,9 @@ async function run({ restart, addChannels }: CliOptions) {
282
296
  )
283
297
  process.exit(EXIT_NO_RESTART)
284
298
  }
299
+ db.prepare(
300
+ 'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
301
+ ).run(appId, token)
285
302
 
286
303
  for (const { guild, channels } of kimakiChannels) {
287
304
  for (const channel of channels) {
@@ -324,28 +341,16 @@ async function run({ restart, addChannels }: CliOptions) {
324
341
 
325
342
  s.start('Starting OpenCode server...')
326
343
 
327
- let client: OpencodeClient
328
-
329
- try {
330
- const currentDir = process.cwd()
331
- client = await initializeOpencodeForDirectory(currentDir)
332
- s.stop('OpenCode server started!')
333
- } catch (error) {
334
- s.stop('Failed to start OpenCode')
335
- cliLogger.error(
336
- 'Error:',
337
- error instanceof Error ? error.message : String(error),
338
- )
339
- discordClient.destroy()
340
- process.exit(EXIT_NO_RESTART)
341
- }
344
+ const currentDir = process.cwd()
345
+ let getClient = await initializeOpencodeForDirectory(currentDir)
346
+ s.stop('OpenCode server started!')
342
347
 
343
348
  s.start('Fetching OpenCode projects...')
344
349
 
345
350
  let projects: Project[] = []
346
351
 
347
352
  try {
348
- const projectsResponse = await client.project.list()
353
+ const projectsResponse = await getClient().project.list()
349
354
  if (!projectsResponse.data) {
350
355
  throw new Error('Failed to fetch projects')
351
356
  }
@@ -362,11 +367,15 @@ async function run({ restart, addChannels }: CliOptions) {
362
367
  }
363
368
 
364
369
  const existingDirs = kimakiChannels.flatMap(({ channels }) =>
365
- channels.map((ch) => ch.kimakiDirectory).filter(Boolean),
370
+ channels
371
+ .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
372
+ .map((ch) => ch.kimakiDirectory)
373
+ .filter(Boolean),
366
374
  )
367
375
 
368
- const availableProjects = projects.filter(
369
- (project) => !existingDirs.includes(project.worktree),
376
+ const availableProjects = deduplicateByKey(
377
+ projects.filter((project) => !existingDirs.includes(project.worktree)),
378
+ (x) => x.worktree,
370
379
  )
371
380
 
372
381
  if (availableProjects.length === 0) {
@@ -376,7 +385,10 @@ async function run({ restart, addChannels }: CliOptions) {
376
385
  )
377
386
  }
378
387
 
379
- if (shouldAddChannels && availableProjects.length > 0) {
388
+ if (
389
+ (!existingDirs?.length && availableProjects.length > 0) ||
390
+ shouldAddChannels
391
+ ) {
380
392
  const selectedProjects = await multiselect({
381
393
  message: 'Select projects to create Discord channels for:',
382
394
  options: availableProjects.map((project) => ({
@@ -422,7 +434,7 @@ async function run({ restart, addChannels }: CliOptions) {
422
434
  if (!project) continue
423
435
 
424
436
  const baseName = path.basename(project.worktree)
425
- const channelName = `kimaki-${baseName}`
437
+ const channelName = `${baseName}`
426
438
  .toLowerCase()
427
439
  .replace(/[^a-z0-9-]/g, '-')
428
440
  .slice(0, 100)
package/src/discordBot.ts CHANGED
@@ -40,7 +40,7 @@ import { PassThrough, Transform, type TransformCallback } from 'node:stream'
40
40
  import * as prism from 'prism-media'
41
41
  import dedent from 'string-dedent'
42
42
  import { transcribeAudio } from './voice.js'
43
- import { extractTagsArrays } from './xml.js'
43
+ import { extractTagsArrays, extractNonXmlContent } from './xml.js'
44
44
  import prettyMilliseconds from 'pretty-ms'
45
45
  import type { Session } from '@google/genai'
46
46
  import { createLogger } from './logger.js'
@@ -334,9 +334,9 @@ async function setupVoiceHandling({
334
334
  .on('data', (frame: Buffer) => {
335
335
  // Check if a newer speaking session has started
336
336
  if (currentSessionCount !== speakingSessionCount) {
337
- voiceLogger.log(
338
- `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
339
- )
337
+ // voiceLogger.log(
338
+ // `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
339
+ // )
340
340
  return
341
341
  }
342
342
 
@@ -346,7 +346,7 @@ async function setupVoiceHandling({
346
346
  )
347
347
  return
348
348
  }
349
- voiceLogger.debug('User audio chunk length', frame.length)
349
+ // voiceLogger.debug('User audio chunk length', frame.length)
350
350
 
351
351
  // Write to PCM file if stream exists
352
352
  voiceData.userAudioStream?.write(frame)
@@ -1942,10 +1942,16 @@ export async function startDiscordBot({
1942
1942
  const userParts = message.parts.filter(
1943
1943
  (p) => p.type === 'text',
1944
1944
  )
1945
- const userText = userParts
1946
- .map((p) => (typeof p.text === 'string' ? p.text : ''))
1945
+ const userTexts = userParts
1946
+ .map((p) => {
1947
+ if (typeof p.text === 'string') {
1948
+ return extractNonXmlContent(p.text)
1949
+ }
1950
+ return ''
1951
+ })
1947
1952
  .filter((t) => t.trim())
1948
- .join('\n\n')
1953
+
1954
+ const userText = userTexts.join('\n\n')
1949
1955
  if (userText) {
1950
1956
  // Escape backticks in user messages to prevent formatting issues
1951
1957
  const escapedText = escapeDiscordFormatting(userText)
package/src/markdown.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OpencodeClient } from '@opencode-ai/sdk'
2
2
  import { format } from 'date-fns'
3
3
  import * as yaml from 'js-yaml'
4
+ import { extractNonXmlContent } from './xml.js'
4
5
 
5
6
  export class ShareMarkdown {
6
7
  constructor(private client: OpencodeClient) {}
@@ -95,8 +96,11 @@ export class ShareMarkdown {
95
96
 
96
97
  for (const part of parts) {
97
98
  if (part.type === 'text' && part.text) {
98
- lines.push(part.text)
99
- lines.push('')
99
+ const cleanedText = extractNonXmlContent(part.text)
100
+ if (cleanedText.trim()) {
101
+ lines.push(cleanedText)
102
+ lines.push('')
103
+ }
100
104
  } else if (part.type === 'file') {
101
105
  lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`)
102
106
  if (part.url) {
package/src/tools.ts CHANGED
@@ -26,11 +26,12 @@ export async function getTools({
26
26
  sessionId: string
27
27
  messageId: string
28
28
  data?: { info: AssistantMessage }
29
- error?: any
29
+ error?: unknown
30
30
  markdown?: string
31
31
  }) => void
32
32
  }) {
33
- const client = await initializeOpencodeForDirectory(directory)
33
+ const getClient = await initializeOpencodeForDirectory(directory)
34
+ const client = getClient()
34
35
 
35
36
  const markdownRenderer = new ShareMarkdown(client)
36
37
 
@@ -41,7 +42,7 @@ export async function getTools({
41
42
  const getSessionModel = async (
42
43
  sessionId: string,
43
44
  ): Promise<{ providerID: string; modelID: string } | undefined> => {
44
- const res = await client.session.messages({ path: { id: sessionId } })
45
+ const res = await getClient().session.messages({ path: { id: sessionId } })
45
46
  const data = res.data
46
47
  if (!data || data.length === 0) return undefined
47
48
  for (let i = data.length - 1; i >= 0; i--) {
@@ -68,8 +69,8 @@ export async function getTools({
68
69
  const sessionModel = await getSessionModel(sessionId)
69
70
 
70
71
  // do not await
71
- client.session
72
- .prompt({
72
+ getClient()
73
+ .session.prompt({
73
74
  path: { id: sessionId },
74
75
 
75
76
  body: {
@@ -132,7 +133,7 @@ export async function getTools({
132
133
  }
133
134
 
134
135
  try {
135
- const session = await client.session.create({
136
+ const session = await getClient().session.create({
136
137
  body: {
137
138
  title: title || message.slice(0, 50),
138
139
  },
@@ -143,8 +144,8 @@ export async function getTools({
143
144
  }
144
145
 
145
146
  // do not await
146
- client.session
147
- .prompt({
147
+ getClient()
148
+ .session.prompt({
148
149
  path: { id: session.data.id },
149
150
  body: {
150
151
  parts: [{ type: 'text', text: message }],
@@ -193,7 +194,7 @@ export async function getTools({
193
194
  inputSchema: z.object({}),
194
195
  execute: async () => {
195
196
  toolsLogger.log(`Listing opencode sessions`)
196
- const sessions = await client.session.list()
197
+ const sessions = await getClient().session.list()
197
198
 
198
199
  if (!sessions.data) {
199
200
  return { success: false, error: 'No sessions found' }
@@ -209,7 +210,7 @@ export async function getTools({
209
210
  const finishedAt = session.time.updated
210
211
  const status = await (async () => {
211
212
  if (session.revert) return 'error'
212
- const messagesResponse = await client.session.messages({
213
+ const messagesResponse = await getClient().session.messages({
213
214
  path: { id: session.id },
214
215
  })
215
216
  const messages = messagesResponse.data || []
@@ -256,7 +257,7 @@ export async function getTools({
256
257
  query: z.string().describe('The search query for files'),
257
258
  }),
258
259
  execute: async ({ folder, query }) => {
259
- const results = await client.find.files({
260
+ const results = await getClient().find.files({
260
261
  query: {
261
262
  query,
262
263
  directory: folder,
@@ -281,7 +282,7 @@ export async function getTools({
281
282
  }),
282
283
  execute: async ({ sessionId, lastAssistantOnly = false }) => {
283
284
  if (lastAssistantOnly) {
284
- const messages = await client.session.messages({
285
+ const messages = await getClient().session.messages({
285
286
  path: { id: sessionId },
286
287
  })
287
288
 
@@ -322,7 +323,7 @@ export async function getTools({
322
323
  sessionID: sessionId,
323
324
  })
324
325
 
325
- const messages = await client.session.messages({
326
+ const messages = await getClient().session.messages({
326
327
  path: { id: sessionId },
327
328
  })
328
329
  const lastMessage = messages.data?.[messages.data.length - 1]
@@ -350,7 +351,7 @@ export async function getTools({
350
351
  }),
351
352
  execute: async ({ sessionId }) => {
352
353
  try {
353
- const result = await client.session.abort({
354
+ const result = await getClient().session.abort({
354
355
  path: { id: sessionId },
355
356
  })
356
357
 
@@ -381,7 +382,7 @@ export async function getTools({
381
382
  inputSchema: z.object({}),
382
383
  execute: async () => {
383
384
  try {
384
- const providersResponse = await client.config.providers({})
385
+ const providersResponse = await getClient().config.providers({})
385
386
  const providers: Provider[] = providersResponse.data?.providers || []
386
387
 
387
388
  const models: Array<{ providerId: string; modelId: string }> = []
package/src/utils.ts CHANGED
@@ -48,26 +48,15 @@ export function generateBotInstallUrl({
48
48
  return url.toString()
49
49
  }
50
50
 
51
- function getRequiredBotPermissions(): bigint[] {
52
- return [
53
- PermissionsBitField.Flags.ViewChannel,
54
- PermissionsBitField.Flags.ManageChannels,
55
- PermissionsBitField.Flags.SendMessages,
56
- PermissionsBitField.Flags.SendMessagesInThreads,
57
- PermissionsBitField.Flags.CreatePublicThreads,
58
- PermissionsBitField.Flags.ManageThreads,
59
- PermissionsBitField.Flags.ReadMessageHistory,
60
- PermissionsBitField.Flags.AddReactions,
61
- PermissionsBitField.Flags.ManageMessages,
62
- PermissionsBitField.Flags.UseExternalEmojis,
63
- PermissionsBitField.Flags.AttachFiles,
64
- PermissionsBitField.Flags.Connect,
65
- PermissionsBitField.Flags.Speak,
66
- ]
67
- }
68
51
 
69
- function getPermissionNames(): string[] {
70
- const permissions = getRequiredBotPermissions()
71
- const permissionsBitField = new PermissionsBitField(permissions)
72
- return permissionsBitField.toArray()
52
+ export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
53
+ const seen = new Set<K>()
54
+ return arr.filter(item => {
55
+ const key = keyFn(item)
56
+ if (seen.has(key)) {
57
+ return false
58
+ }
59
+ seen.add(key)
60
+ return true
61
+ })
73
62
  }
@@ -0,0 +1,37 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { extractNonXmlContent } from './xml.js'
3
+
4
+ describe('extractNonXmlContent', () => {
5
+ test('removes xml tags and returns only text content', () => {
6
+ const xml = 'Hello <tag>content</tag> world <nested><inner>deep</inner></nested> end'
7
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
8
+ "Hello
9
+ world
10
+ end"
11
+ `)
12
+ })
13
+
14
+ test('handles multiple text segments', () => {
15
+ const xml = 'Start <a>tag1</a> middle <b>tag2</b> finish'
16
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
17
+ "Start
18
+ middle
19
+ finish"
20
+ `)
21
+ })
22
+
23
+ test('handles only xml without text', () => {
24
+ const xml = '<root><child>content</child></root>'
25
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`)
26
+ })
27
+
28
+ test('handles only text without xml', () => {
29
+ const xml = 'Just plain text'
30
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`"Just plain text"`)
31
+ })
32
+
33
+ test('handles empty string', () => {
34
+ const xml = ''
35
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`)
36
+ })
37
+ })
package/src/xml.ts CHANGED
@@ -110,3 +110,8 @@ export function extractTagsArrays<T extends string>({
110
110
 
111
111
  return result as Record<T, string[]> & { others: string[] }
112
112
  }
113
+
114
+ export function extractNonXmlContent(xml: string): string {
115
+ const result = extractTagsArrays({ xml, tags: [] })
116
+ return result.others.join('\n')
117
+ }
package/bin.sh DELETED
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Restarts dist/cli.js if it exits non-zero.
3
- # Throttles restarts to at most once every 5 seconds.
4
-
5
- set -u -o pipefail
6
-
7
- NODE_BIN="${NODE_BIN:-node}"
8
-
9
-
10
- last_start=0
11
-
12
- while :; do
13
- now=$(date +%s)
14
- elapsed=$(( now - last_start ))
15
- if (( elapsed < 5 )); then
16
- sleep $(( 5 - elapsed ))
17
- fi
18
- last_start=$(date +%s)
19
-
20
- "$NODE_BIN" "./dist/cli.js" "$@"
21
- code=$?
22
-
23
- # Exit cleanly if the app ended OK or via SIGINT/SIGTERM
24
- if (( code == 0 || code == 130 || code == 143 || code == 64 )); then
25
- exit "$code"
26
- fi
27
- # otherwise loop; the 5s throttle above will apply
28
- done