kimaki 0.2.1 → 0.3.1

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.
@@ -18,6 +18,10 @@ import { transcribeAudio } from './voice.js';
18
18
  import { extractTagsArrays, extractNonXmlContent } from './xml.js';
19
19
  import prettyMilliseconds from 'pretty-ms';
20
20
  import { createLogger } from './logger.js';
21
+ import { isAbortError } from './utils.js';
22
+ import { setGlobalDispatcher, Agent } from 'undici';
23
+ // disables the automatic 5 minutes abort after no body
24
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
21
25
  const discordLogger = createLogger('DISCORD');
22
26
  const voiceLogger = createLogger('VOICE');
23
27
  const opencodeLogger = createLogger('OPENCODE');
@@ -615,6 +619,41 @@ export async function initializeOpencodeForDirectory(directory) {
615
619
  cwd: directory,
616
620
  env: {
617
621
  ...process.env,
622
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
623
+ $schema: 'https://opencode.ai/config.json',
624
+ lsp: {
625
+ typescript: { disabled: true },
626
+ eslint: { disabled: true },
627
+ gopls: { disabled: true },
628
+ 'ruby-lsp': { disabled: true },
629
+ pyright: { disabled: true },
630
+ 'elixir-ls': { disabled: true },
631
+ zls: { disabled: true },
632
+ csharp: { disabled: true },
633
+ vue: { disabled: true },
634
+ rust: { disabled: true },
635
+ clangd: { disabled: true },
636
+ svelte: { disabled: true },
637
+ },
638
+ formatter: {
639
+ prettier: { disabled: true },
640
+ biome: { disabled: true },
641
+ gofmt: { disabled: true },
642
+ mix: { disabled: true },
643
+ zig: { disabled: true },
644
+ 'clang-format': { disabled: true },
645
+ ktlint: { disabled: true },
646
+ ruff: { disabled: true },
647
+ rubocop: { disabled: true },
648
+ standardrb: { disabled: true },
649
+ htmlbeautifier: { disabled: true },
650
+ },
651
+ permission: {
652
+ edit: 'allow',
653
+ bash: 'allow',
654
+ webfetch: 'allow',
655
+ },
656
+ }),
618
657
  OPENCODE_PORT: port.toString(),
619
658
  },
620
659
  });
@@ -679,22 +718,29 @@ function formatPart(part) {
679
718
  return `▪︎ thinking: ${escapeDiscordFormatting(part.text || '')}`;
680
719
  case 'tool':
681
720
  if (part.state.status === 'completed' || part.state.status === 'error') {
682
- // console.log(part)
683
- // Escape triple backticks so Discord does not break code blocks
684
721
  let language = '';
685
722
  let outputToDisplay = '';
723
+ let summaryText = '';
686
724
  if (part.tool === 'bash') {
687
- outputToDisplay =
688
- part.state.status === 'completed'
689
- ? part.state.output
690
- : part.state.error;
691
- outputToDisplay ||= '';
725
+ const output = part.state.status === 'completed'
726
+ ? part.state.output
727
+ : part.state.error;
728
+ const lines = (output || '').split('\n').filter((l) => l.trim());
729
+ summaryText = `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
692
730
  }
693
- if (part.tool === 'edit') {
694
- outputToDisplay = part.state.input?.newString || '';
695
- language = path.extname(part.state.input.filePath || '');
731
+ else if (part.tool === 'edit') {
732
+ const newString = part.state.input?.newString || '';
733
+ const oldString = part.state.input?.oldString || '';
734
+ const added = newString.split('\n').length;
735
+ const removed = oldString.split('\n').length;
736
+ summaryText = `(+${added}-${removed})`;
696
737
  }
697
- if (part.tool === 'todowrite') {
738
+ else if (part.tool === 'write') {
739
+ const content = part.state.input?.content || '';
740
+ const lines = content.split('\n').length;
741
+ summaryText = `(${lines} line${lines === 1 ? '' : 's'})`;
742
+ }
743
+ else if (part.tool === 'todowrite') {
698
744
  const todos = part.state.input?.todos || [];
699
745
  outputToDisplay = todos
700
746
  .map((todo) => {
@@ -717,20 +763,27 @@ function formatPart(part) {
717
763
  })
718
764
  .filter(Boolean)
719
765
  .join('\n');
720
- language = '';
721
766
  }
722
- if (part.tool === 'write') {
723
- outputToDisplay = part.state.input?.content || '';
724
- language = path.extname(part.state.input.filePath || '');
767
+ else if (part.tool === 'webfetch') {
768
+ const url = part.state.input?.url || '';
769
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
770
+ summaryText = urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
771
+ }
772
+ else if (part.state.input) {
773
+ const inputFields = Object.entries(part.state.input)
774
+ .map(([key, value]) => {
775
+ if (value === null || value === undefined)
776
+ return null;
777
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
778
+ const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue;
779
+ return `${key}: ${truncatedValue}`;
780
+ })
781
+ .filter(Boolean);
782
+ if (inputFields.length > 0) {
783
+ outputToDisplay = inputFields.join('\n');
784
+ }
725
785
  }
726
- outputToDisplay =
727
- outputToDisplay.length > 500
728
- ? outputToDisplay.slice(0, 497) + `…`
729
- : outputToDisplay;
730
- // Escape Discord formatting characters that could break code blocks
731
- outputToDisplay = escapeDiscordFormatting(outputToDisplay);
732
786
  let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
733
- // Escape backticks in the title before wrapping in backticks
734
787
  if (toolTitle) {
735
788
  toolTitle = `\`${escapeInlineCode(toolTitle)}\``;
736
789
  }
@@ -739,19 +792,10 @@ function formatPart(part) {
739
792
  : part.state.status === 'error'
740
793
  ? '⨯'
741
794
  : '';
742
- const title = `${icon} ${part.tool} ${toolTitle}`;
795
+ const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
743
796
  let text = title;
744
797
  if (outputToDisplay) {
745
- // Don't wrap todowrite output in code blocks
746
- if (part.tool === 'todowrite') {
747
- text += '\n\n' + outputToDisplay;
748
- }
749
- else {
750
- if (language.startsWith('.')) {
751
- language = language.slice(1);
752
- }
753
- text += '\n\n```' + language + '\n' + outputToDisplay + '\n```';
754
- }
798
+ text += '\n\n' + outputToDisplay;
755
799
  }
756
800
  return text;
757
801
  }
@@ -1041,8 +1085,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1041
1085
  }
1042
1086
  }
1043
1087
  catch (e) {
1044
- if (e instanceof Error && e.name === 'AbortError') {
1045
- // Ignore abort controller errors as requested
1088
+ if (isAbortError(e, abortController.signal)) {
1046
1089
  sessionLogger.log('AbortController aborted event handling (normal exit)');
1047
1090
  return;
1048
1091
  }
@@ -1118,7 +1161,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1118
1161
  }
1119
1162
  catch (error) {
1120
1163
  sessionLogger.error(`ERROR: Failed to send prompt:`, error);
1121
- if (!(error instanceof Error && error.name === 'AbortError')) {
1164
+ if (!isAbortError(error, abortController.signal)) {
1122
1165
  abortController.abort('error');
1123
1166
  if (originalMessage) {
1124
1167
  try {
@@ -1130,7 +1173,14 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1130
1173
  discordLogger.log(`Could not update reaction:`, e);
1131
1174
  }
1132
1175
  }
1133
- await sendThreadMessage(thread, `✗ Unexpected bot Error: ${error instanceof Error ? error.stack || error.message : String(error)}`);
1176
+ // Always log the error's constructor name (if any) and make error reporting more readable
1177
+ const errorName = error && typeof error === 'object' && 'constructor' in error && error.constructor && typeof error.constructor.name === 'string'
1178
+ ? error.constructor.name
1179
+ : typeof error;
1180
+ const errorMsg = error instanceof Error
1181
+ ? (error.stack || error.message)
1182
+ : String(error);
1183
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
1134
1184
  }
1135
1185
  }
1136
1186
  }
package/dist/utils.js CHANGED
@@ -30,7 +30,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
30
30
  }
31
31
  export function deduplicateByKey(arr, keyFn) {
32
32
  const seen = new Set();
33
- return arr.filter(item => {
33
+ return arr.filter((item) => {
34
34
  const key = keyFn(item);
35
35
  if (seen.has(key)) {
36
36
  return false;
@@ -39,3 +39,12 @@ export function deduplicateByKey(arr, keyFn) {
39
39
  return true;
40
40
  });
41
41
  }
42
+ export function isAbortError(error, signal) {
43
+ return (error instanceof Error &&
44
+ (error.name === 'AbortError' ||
45
+ error.name === 'Aborterror' ||
46
+ error.name === 'aborterror' ||
47
+ error.name.toLowerCase() === 'aborterror' ||
48
+ error.message?.includes('aborted') ||
49
+ (signal?.aborted ?? false)));
50
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.2.1",
5
+ "version": "0.3.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -41,10 +41,11 @@
41
41
  "pretty-ms": "^9.3.0",
42
42
  "prism-media": "^1.3.5",
43
43
  "string-dedent": "^3.0.2",
44
+ "undici": "^7.16.0",
44
45
  "zod": "^4.0.17"
45
46
  },
46
47
  "scripts": {
47
- "dev": "pnpm tsc && DEBUG=1 tsx --env-file .env src/cli.ts",
48
+ "dev": "pnpm tsc && tsx --env-file .env src/cli.ts",
48
49
  "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
49
50
  "test": "tsx scripts/test-opencode.ts",
50
51
  "watch": "tsx scripts/watch-session.ts",
package/src/discordBot.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  createOpencodeClient,
3
3
  type OpencodeClient,
4
4
  type Part,
5
+ type Config,
5
6
  } from '@opencode-ai/sdk'
6
7
 
7
8
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -45,6 +46,10 @@ import { extractTagsArrays, extractNonXmlContent } from './xml.js'
45
46
  import prettyMilliseconds from 'pretty-ms'
46
47
  import type { Session } from '@google/genai'
47
48
  import { createLogger } from './logger.js'
49
+ import { isAbortError } from './utils.js'
50
+ import { setGlobalDispatcher, Agent } from 'undici'
51
+ // disables the automatic 5 minutes abort after no body
52
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
48
53
 
49
54
  const discordLogger = createLogger('DISCORD')
50
55
  const voiceLogger = createLogger('VOICE')
@@ -833,6 +838,41 @@ export async function initializeOpencodeForDirectory(directory: string) {
833
838
  cwd: directory,
834
839
  env: {
835
840
  ...process.env,
841
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
842
+ $schema: 'https://opencode.ai/config.json',
843
+ lsp: {
844
+ typescript: { disabled: true },
845
+ eslint: { disabled: true },
846
+ gopls: { disabled: true },
847
+ 'ruby-lsp': { disabled: true },
848
+ pyright: { disabled: true },
849
+ 'elixir-ls': { disabled: true },
850
+ zls: { disabled: true },
851
+ csharp: { disabled: true },
852
+ vue: { disabled: true },
853
+ rust: { disabled: true },
854
+ clangd: { disabled: true },
855
+ svelte: { disabled: true },
856
+ },
857
+ formatter: {
858
+ prettier: { disabled: true },
859
+ biome: { disabled: true },
860
+ gofmt: { disabled: true },
861
+ mix: { disabled: true },
862
+ zig: { disabled: true },
863
+ 'clang-format': { disabled: true },
864
+ ktlint: { disabled: true },
865
+ ruff: { disabled: true },
866
+ rubocop: { disabled: true },
867
+ standardrb: { disabled: true },
868
+ htmlbeautifier: { disabled: true },
869
+ },
870
+ permission: {
871
+ edit: 'allow',
872
+ bash: 'allow',
873
+ webfetch: 'allow',
874
+ },
875
+ } satisfies Config),
836
876
  OPENCODE_PORT: port.toString(),
837
877
  },
838
878
  },
@@ -914,22 +954,28 @@ function formatPart(part: Part): string {
914
954
  return `▪︎ thinking: ${escapeDiscordFormatting(part.text || '')}`
915
955
  case 'tool':
916
956
  if (part.state.status === 'completed' || part.state.status === 'error') {
917
- // console.log(part)
918
- // Escape triple backticks so Discord does not break code blocks
919
957
  let language = ''
920
958
  let outputToDisplay = ''
959
+ let summaryText = ''
960
+
921
961
  if (part.tool === 'bash') {
922
- outputToDisplay =
962
+ const output =
923
963
  part.state.status === 'completed'
924
964
  ? part.state.output
925
965
  : part.state.error
926
- outputToDisplay ||= ''
927
- }
928
- if (part.tool === 'edit') {
929
- outputToDisplay = (part.state.input?.newString as string) || ''
930
- language = path.extname((part.state.input.filePath as string) || '')
931
- }
932
- if (part.tool === 'todowrite') {
966
+ const lines = (output || '').split('\n').filter((l) => l.trim())
967
+ summaryText = `(${lines.length} line${lines.length === 1 ? '' : 's'})`
968
+ } else if (part.tool === 'edit') {
969
+ const newString = (part.state.input?.newString as string) || ''
970
+ const oldString = (part.state.input?.oldString as string) || ''
971
+ const added = newString.split('\n').length
972
+ const removed = oldString.split('\n').length
973
+ summaryText = `(+${added}-${removed})`
974
+ } else if (part.tool === 'write') {
975
+ const content = (part.state.input?.content as string) || ''
976
+ const lines = content.split('\n').length
977
+ summaryText = `(${lines} line${lines === 1 ? '' : 's'})`
978
+ } else if (part.tool === 'todowrite') {
933
979
  const todos =
934
980
  (part.state.input?.todos as {
935
981
  content: string
@@ -956,23 +1002,26 @@ function formatPart(part: Part): string {
956
1002
  })
957
1003
  .filter(Boolean)
958
1004
  .join('\n')
959
- language = ''
960
- }
961
- if (part.tool === 'write') {
962
- outputToDisplay = (part.state.input?.content as string) || ''
963
- language = path.extname((part.state.input.filePath as string) || '')
1005
+ } else if (part.tool === 'webfetch') {
1006
+ const url = (part.state.input?.url as string) || ''
1007
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1008
+ summaryText = urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1009
+ } else if (part.state.input) {
1010
+ const inputFields = Object.entries(part.state.input)
1011
+ .map(([key, value]) => {
1012
+ if (value === null || value === undefined) return null
1013
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1014
+ const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue
1015
+ return `${key}: ${truncatedValue}`
1016
+ })
1017
+ .filter(Boolean)
1018
+ if (inputFields.length > 0) {
1019
+ outputToDisplay = inputFields.join('\n')
1020
+ }
964
1021
  }
965
- outputToDisplay =
966
- outputToDisplay.length > 500
967
- ? outputToDisplay.slice(0, 497) + `…`
968
- : outputToDisplay
969
-
970
- // Escape Discord formatting characters that could break code blocks
971
- outputToDisplay = escapeDiscordFormatting(outputToDisplay)
972
1022
 
973
1023
  let toolTitle =
974
1024
  part.state.status === 'completed' ? part.state.title || '' : 'error'
975
- // Escape backticks in the title before wrapping in backticks
976
1025
  if (toolTitle) {
977
1026
  toolTitle = `\`${escapeInlineCode(toolTitle)}\``
978
1027
  }
@@ -982,20 +1031,12 @@ function formatPart(part: Part): string {
982
1031
  : part.state.status === 'error'
983
1032
  ? '⨯'
984
1033
  : ''
985
- const title = `${icon} ${part.tool} ${toolTitle}`
1034
+ const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
986
1035
 
987
1036
  let text = title
988
1037
 
989
1038
  if (outputToDisplay) {
990
- // Don't wrap todowrite output in code blocks
991
- if (part.tool === 'todowrite') {
992
- text += '\n\n' + outputToDisplay
993
- } else {
994
- if (language.startsWith('.')) {
995
- language = language.slice(1)
996
- }
997
- text += '\n\n```' + language + '\n' + outputToDisplay + '\n```'
998
- }
1039
+ text += '\n\n' + outputToDisplay
999
1040
  }
1000
1041
  return text
1001
1042
  }
@@ -1364,8 +1405,7 @@ async function handleOpencodeSession(
1364
1405
  }
1365
1406
  }
1366
1407
  } catch (e) {
1367
- if (e instanceof Error && e.name === 'AbortError') {
1368
- // Ignore abort controller errors as requested
1408
+ if (isAbortError(e, abortController.signal)) {
1369
1409
  sessionLogger.log(
1370
1410
  'AbortController aborted event handling (normal exit)',
1371
1411
  )
@@ -1462,7 +1502,7 @@ async function handleOpencodeSession(
1462
1502
  } catch (error) {
1463
1503
  sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1464
1504
 
1465
- if (!(error instanceof Error && error.name === 'AbortError')) {
1505
+ if (!isAbortError(error, abortController.signal)) {
1466
1506
  abortController.abort('error')
1467
1507
 
1468
1508
  if (originalMessage) {
@@ -1474,9 +1514,16 @@ async function handleOpencodeSession(
1474
1514
  discordLogger.log(`Could not update reaction:`, e)
1475
1515
  }
1476
1516
  }
1517
+ // Always log the error's constructor name (if any) and make error reporting more readable
1518
+ const errorName = error && typeof error === 'object' && 'constructor' in error && error.constructor && typeof error.constructor.name === 'string'
1519
+ ? error.constructor.name
1520
+ : typeof error
1521
+ const errorMsg = error instanceof Error
1522
+ ? (error.stack || error.message)
1523
+ : String(error)
1477
1524
  await sendThreadMessage(
1478
1525
  thread,
1479
- `✗ Unexpected bot Error: ${error instanceof Error ? error.stack || error.message : String(error)}`,
1526
+ `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
1480
1527
  )
1481
1528
  }
1482
1529
  }
package/src/utils.ts CHANGED
@@ -48,10 +48,9 @@ export function generateBotInstallUrl({
48
48
  return url.toString()
49
49
  }
50
50
 
51
-
52
51
  export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
53
52
  const seen = new Set<K>()
54
- return arr.filter(item => {
53
+ return arr.filter((item) => {
55
54
  const key = keyFn(item)
56
55
  if (seen.has(key)) {
57
56
  return false
@@ -60,3 +59,18 @@ export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
60
59
  return true
61
60
  })
62
61
  }
62
+
63
+ export function isAbortError(
64
+ error: unknown,
65
+ signal?: AbortSignal,
66
+ ): error is Error {
67
+ return (
68
+ error instanceof Error &&
69
+ (error.name === 'AbortError' ||
70
+ error.name === 'Aborterror' ||
71
+ error.name === 'aborterror' ||
72
+ error.name.toLowerCase() === 'aborterror' ||
73
+ error.message?.includes('aborted') ||
74
+ (signal?.aborted ?? false))
75
+ )
76
+ }