kimaki 0.1.0 → 0.1.2

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
@@ -197,22 +197,13 @@ async function run({ restart, addChannels }) {
197
197
  note(channelList, 'Existing Kimaki Channels');
198
198
  }
199
199
  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
- }
200
+ const currentDir = process.cwd();
201
+ let getClient = await initializeOpencodeForDirectory(currentDir);
202
+ s.stop('OpenCode server started!');
212
203
  s.start('Fetching OpenCode projects...');
213
204
  let projects = [];
214
205
  try {
215
- const projectsResponse = await client.project.list();
206
+ const projectsResponse = await getClient().project.list();
216
207
  if (!projectsResponse.data) {
217
208
  throw new Error('Failed to fetch projects');
218
209
  }
@@ -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
@@ -551,29 +553,19 @@ function getKimakiMetadata(textChannel) {
551
553
  const channelAppId = extracted['kimaki.app']?.[0]?.trim();
552
554
  return { projectDirectory, channelAppId };
553
555
  }
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
556
  export async function initializeOpencodeForDirectory(directory) {
571
557
  // console.log(`[OPENCODE] Initializing for directory: ${directory}`)
572
558
  // Check if we already have a server for this directory
573
559
  const existing = opencodeServers.get(directory);
574
560
  if (existing && !existing.process.killed) {
575
561
  opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
576
- return existing.client;
562
+ return () => {
563
+ const entry = opencodeServers.get(directory);
564
+ if (!entry?.client) {
565
+ throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
566
+ }
567
+ return entry.client;
568
+ };
577
569
  }
578
570
  const port = await getOpenPort();
579
571
  // console.log(
@@ -589,17 +581,34 @@ export async function initializeOpencodeForDirectory(directory) {
589
581
  },
590
582
  });
591
583
  serverProcess.stdout?.on('data', (data) => {
592
- opencodeLogger.log(`Port ${port}: ${data.toString().trim()}`);
584
+ opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
593
585
  });
594
586
  serverProcess.stderr?.on('data', (data) => {
595
- opencodeLogger.error(`Port error: ${data.toString().trim()}`);
587
+ opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
596
588
  });
597
589
  serverProcess.on('error', (error) => {
598
- opencodeLogger.error(`Failed to start server on port :`, error);
590
+ opencodeLogger.error(`Failed to start server on port :`, port, error);
599
591
  });
600
592
  serverProcess.on('exit', (code) => {
601
- opencodeLogger.log(`Server on port exited with code:`, code);
593
+ opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
602
594
  opencodeServers.delete(directory);
595
+ if (code !== 0) {
596
+ const retryCount = serverRetryCount.get(directory) || 0;
597
+ if (retryCount < 5) {
598
+ serverRetryCount.set(directory, retryCount + 1);
599
+ opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
600
+ initializeOpencodeForDirectory(directory).catch((e) => {
601
+ opencodeLogger.error(`Failed to restart opencode server:`, e);
602
+ });
603
+ }
604
+ else {
605
+ opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
606
+ }
607
+ }
608
+ else {
609
+ // Reset retry count on clean exit
610
+ serverRetryCount.delete(directory);
611
+ }
603
612
  });
604
613
  await waitForServer(port);
605
614
  const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
@@ -608,7 +617,13 @@ export async function initializeOpencodeForDirectory(directory) {
608
617
  client,
609
618
  port,
610
619
  });
611
- return client;
620
+ return () => {
621
+ const entry = opencodeServers.get(directory);
622
+ if (!entry?.client) {
623
+ throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
624
+ }
625
+ return entry.client;
626
+ };
612
627
  }
613
628
  function formatPart(part) {
614
629
  switch (part.type) {
@@ -746,7 +761,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
746
761
  const directory = projectDirectory || process.cwd();
747
762
  sessionLogger.log(`Using directory: ${directory}`);
748
763
  // Note: We'll cancel the existing request after we have the session ID
749
- const client = await initializeOpencodeForDirectory(directory);
764
+ const getClient = await initializeOpencodeForDirectory(directory);
750
765
  // Get session ID from database
751
766
  const row = getDatabase()
752
767
  .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
@@ -756,7 +771,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
756
771
  if (sessionId) {
757
772
  sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
758
773
  try {
759
- const sessionResponse = await client.session.get({
774
+ const sessionResponse = await getClient().session.get({
760
775
  path: { id: sessionId },
761
776
  });
762
777
  session = sessionResponse.data;
@@ -768,7 +783,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
768
783
  }
769
784
  if (!session) {
770
785
  voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
771
- const sessionResponse = await client.session.create({
786
+ const sessionResponse = await getClient().session.create({
772
787
  body: { title: prompt.slice(0, 80) },
773
788
  });
774
789
  session = sessionResponse.data;
@@ -794,7 +809,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
794
809
  const abortController = new AbortController();
795
810
  // Store this controller for this session
796
811
  abortControllers.set(session.id, abortController);
797
- const eventsResult = await client.event.subscribe({
812
+ const eventsResult = await getClient().event.subscribe({
798
813
  signal: abortController.signal,
799
814
  });
800
815
  const events = eventsResult.stream;
@@ -1034,7 +1049,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1034
1049
  voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1035
1050
  // Start the event handler
1036
1051
  const eventHandlerPromise = eventHandler();
1037
- const response = await client.session.prompt({
1052
+ const response = await getClient().session.prompt({
1038
1053
  path: { id: session.id },
1039
1054
  body: {
1040
1055
  parts: [{ type: 'text', text: prompt }],
@@ -1322,9 +1337,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1322
1337
  }
1323
1338
  try {
1324
1339
  // Get OpenCode client for this directory
1325
- const client = await initializeOpencodeForDirectory(projectDirectory);
1340
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1326
1341
  // List sessions
1327
- const sessionsResponse = await client.session.list();
1342
+ const sessionsResponse = await getClient().session.list();
1328
1343
  if (!sessionsResponse.data) {
1329
1344
  await interaction.respond([]);
1330
1345
  return;
@@ -1385,9 +1400,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1385
1400
  }
1386
1401
  try {
1387
1402
  // Initialize OpenCode client for the directory
1388
- const client = await initializeOpencodeForDirectory(projectDirectory);
1403
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1389
1404
  // Get session title
1390
- const sessionResponse = await client.session.get({
1405
+ const sessionResponse = await getClient().session.get({
1391
1406
  path: { id: sessionId },
1392
1407
  });
1393
1408
  if (!sessionResponse.data) {
@@ -1407,7 +1422,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1407
1422
  .run(thread.id, sessionId);
1408
1423
  voiceLogger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`);
1409
1424
  // Fetch all messages for the session
1410
- const messagesResponse = await client.session.messages({
1425
+ const messagesResponse = await getClient().session.messages({
1411
1426
  path: { id: sessionId },
1412
1427
  });
1413
1428
  if (!messagesResponse.data) {
@@ -1423,10 +1438,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1423
1438
  if (message.info.role === 'user') {
1424
1439
  // Render user messages
1425
1440
  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');
1441
+ const userTexts = userParts
1442
+ .map((p) => {
1443
+ if (typeof p.text === 'string') {
1444
+ return extractNonXmlContent(p.text);
1445
+ }
1446
+ return '';
1447
+ })
1448
+ .filter((t) => t.trim());
1449
+ const userText = userTexts.join('\n\n');
1430
1450
  if (userText) {
1431
1451
  // Escape backticks in user messages to prevent formatting issues
1432
1452
  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/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.2",
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
@@ -324,28 +324,16 @@ async function run({ restart, addChannels }: CliOptions) {
324
324
 
325
325
  s.start('Starting OpenCode server...')
326
326
 
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
- }
327
+ const currentDir = process.cwd()
328
+ let getClient = await initializeOpencodeForDirectory(currentDir)
329
+ s.stop('OpenCode server started!')
342
330
 
343
331
  s.start('Fetching OpenCode projects...')
344
332
 
345
333
  let projects: Project[] = []
346
334
 
347
335
  try {
348
- const projectsResponse = await client.project.list()
336
+ const projectsResponse = await getClient().project.list()
349
337
  if (!projectsResponse.data) {
350
338
  throw new Error('Failed to fetch projects')
351
339
  }
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'
@@ -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 }> = []
@@ -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