kimaki 0.4.82 → 0.4.84

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.
Files changed (264) hide show
  1. package/LICENSE +21 -0
  2. package/dist/anthropic-auth-plugin.js +7 -0
  3. package/dist/cli.js +51 -7
  4. package/dist/commands/abort.js +5 -16
  5. package/dist/commands/action-buttons.js +3 -3
  6. package/dist/commands/add-project.js +1 -1
  7. package/dist/commands/ask-question.js +3 -3
  8. package/dist/commands/context-usage.js +1 -1
  9. package/dist/commands/create-new-project.js +1 -1
  10. package/dist/commands/fork.js +11 -8
  11. package/dist/commands/merge-worktree.js +1 -1
  12. package/dist/commands/new-worktree.js +63 -44
  13. package/dist/commands/remove-project.js +1 -1
  14. package/dist/commands/resume.js +11 -8
  15. package/dist/commands/screenshare.js +14 -6
  16. package/dist/commands/screenshare.test.js +20 -0
  17. package/dist/commands/session.js +1 -1
  18. package/dist/commands/undo-redo.js +91 -7
  19. package/dist/commands/user-command.js +1 -1
  20. package/dist/config.js +16 -1
  21. package/dist/database.js +53 -2
  22. package/dist/db.js +6 -0
  23. package/dist/discord-bot.js +48 -85
  24. package/dist/discord-command-registration.js +1 -1
  25. package/dist/external-opencode-sync.js +515 -0
  26. package/dist/external-opencode-sync.test.js +151 -0
  27. package/dist/gateway-proxy.e2e.test.js +8 -5
  28. package/dist/genai.js +1 -1
  29. package/dist/generated/enums.js +4 -0
  30. package/dist/generated/internal/class.js +4 -4
  31. package/dist/generated/internal/prismaNamespace.js +1 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js +1 -0
  33. package/dist/generated/models/external_session_pending_prompts.js +1 -0
  34. package/dist/hrana-server.js +14 -285
  35. package/dist/hrana-server.test.js +4 -2
  36. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +7 -0
  37. package/dist/kimaki-opencode-plugin.js +2 -0
  38. package/dist/kitty-graphics-parser.js +3 -0
  39. package/dist/kitty-graphics-parser.test.js +276 -0
  40. package/dist/kitty-graphics-plugin.js +3 -0
  41. package/dist/markdown.js +4 -4
  42. package/dist/markdown.test.js +1 -1
  43. package/dist/message-formatting.js +54 -15
  44. package/dist/onboarding-tutorial.js +1 -1
  45. package/dist/openai-realtime.js +9 -13
  46. package/dist/opencode.js +28 -5
  47. package/dist/queue-advanced-e2e-setup.js +89 -0
  48. package/dist/queue-advanced-permissions-typing.e2e.test.js +5 -5
  49. package/dist/queue-advanced-typing.e2e.test.js +9 -22
  50. package/dist/queue-question-select-drain.e2e.test.js +117 -0
  51. package/dist/session-handler/event-stream-state.js +101 -7
  52. package/dist/session-handler/event-stream-state.test.js +7 -3
  53. package/dist/session-handler/thread-session-runtime.js +120 -9
  54. package/dist/store.js +1 -0
  55. package/dist/system-message.js +22 -4
  56. package/dist/system-message.test.js +19 -0
  57. package/dist/task-runner.js +1 -1
  58. package/dist/thread-message-queue.e2e.test.js +8 -14
  59. package/dist/tools.js +1 -1
  60. package/dist/undo-redo.e2e.test.js +20 -25
  61. package/package.json +10 -6
  62. package/schema.prisma +6 -0
  63. package/skills/errore/SKILL.md +40 -13
  64. package/skills/goke/SKILL.md +12 -0
  65. package/skills/lintcn/SKILL.md +868 -0
  66. package/skills/npm-package/SKILL.md +1 -0
  67. package/skills/proxyman/SKILL.md +215 -0
  68. package/skills/spiceflow/SKILL.md +1 -1
  69. package/skills/usecomputer/SKILL.md +339 -0
  70. package/src/ai-tool-to-genai.ts +1 -0
  71. package/src/anthropic-auth-plugin.ts +7 -0
  72. package/src/cli.ts +59 -6
  73. package/src/commands/abort.ts +6 -16
  74. package/src/commands/action-buttons.ts +5 -1
  75. package/src/commands/add-project.ts +1 -1
  76. package/src/commands/ask-question.ts +5 -2
  77. package/src/commands/context-usage.ts +1 -1
  78. package/src/commands/create-new-project.ts +1 -1
  79. package/src/commands/fork.ts +12 -11
  80. package/src/commands/merge-worktree.ts +1 -1
  81. package/src/commands/new-worktree.ts +74 -55
  82. package/src/commands/remove-project.ts +1 -1
  83. package/src/commands/resume.ts +12 -10
  84. package/src/commands/screenshare.test.ts +30 -0
  85. package/src/commands/screenshare.ts +18 -6
  86. package/src/commands/session.ts +1 -1
  87. package/src/commands/undo-redo.ts +108 -10
  88. package/src/commands/user-command.ts +1 -1
  89. package/src/config.ts +19 -1
  90. package/src/database.ts +72 -3
  91. package/src/db.ts +8 -0
  92. package/src/discord-bot.ts +58 -93
  93. package/src/discord-command-registration.ts +1 -1
  94. package/src/external-opencode-sync.ts +729 -0
  95. package/src/gateway-proxy.e2e.test.ts +9 -5
  96. package/src/genai.ts +3 -3
  97. package/src/generated/commonInputTypes.ts +34 -0
  98. package/src/generated/enums.ts +8 -0
  99. package/src/generated/internal/class.ts +4 -4
  100. package/src/generated/internal/prismaNamespace.ts +8 -0
  101. package/src/generated/internal/prismaNamespaceBrowser.ts +1 -0
  102. package/src/generated/models/thread_sessions.ts +53 -1
  103. package/src/hrana-server.test.ts +8 -2
  104. package/src/hrana-server.ts +18 -390
  105. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +7 -0
  106. package/src/kimaki-opencode-plugin.ts +2 -0
  107. package/src/markdown.test.ts +1 -1
  108. package/src/markdown.ts +4 -4
  109. package/src/message-formatting.ts +66 -17
  110. package/src/onboarding-tutorial.ts +1 -1
  111. package/src/openai-realtime.ts +6 -10
  112. package/src/opencode.ts +31 -7
  113. package/src/queue-advanced-e2e-setup.ts +92 -0
  114. package/src/queue-advanced-permissions-typing.e2e.test.ts +5 -5
  115. package/src/queue-advanced-typing.e2e.test.ts +9 -22
  116. package/src/queue-question-select-drain.e2e.test.ts +149 -0
  117. package/src/schema.sql +1 -0
  118. package/src/session-handler/event-stream-state.test.ts +7 -2
  119. package/src/session-handler/event-stream-state.ts +128 -7
  120. package/src/session-handler/thread-runtime-state.ts +5 -0
  121. package/src/session-handler/thread-session-runtime.ts +153 -11
  122. package/src/store.ts +8 -0
  123. package/src/system-message.ts +27 -4
  124. package/src/task-runner.ts +1 -1
  125. package/src/thread-message-queue.e2e.test.ts +8 -14
  126. package/src/tools.ts +1 -1
  127. package/src/undo-redo.e2e.test.ts +28 -26
  128. package/skills/jitter/node_modules/.bin/esbuild +0 -21
  129. package/skills/jitter/node_modules/.bin/tsc +0 -21
  130. package/skills/jitter/node_modules/.bin/tsserver +0 -21
  131. package/skills/jitter/node_modules/typescript/LICENSE.txt +0 -55
  132. package/skills/jitter/node_modules/typescript/README.md +0 -50
  133. package/skills/jitter/node_modules/typescript/SECURITY.md +0 -41
  134. package/skills/jitter/node_modules/typescript/ThirdPartyNoticeText.txt +0 -193
  135. package/skills/jitter/node_modules/typescript/bin/tsc +0 -2
  136. package/skills/jitter/node_modules/typescript/bin/tsserver +0 -2
  137. package/skills/jitter/node_modules/typescript/lib/_tsc.js +0 -133792
  138. package/skills/jitter/node_modules/typescript/lib/_tsserver.js +0 -659
  139. package/skills/jitter/node_modules/typescript/lib/_typingsInstaller.js +0 -222
  140. package/skills/jitter/node_modules/typescript/lib/cs/diagnosticMessages.generated.json +0 -2122
  141. package/skills/jitter/node_modules/typescript/lib/de/diagnosticMessages.generated.json +0 -2122
  142. package/skills/jitter/node_modules/typescript/lib/es/diagnosticMessages.generated.json +0 -2122
  143. package/skills/jitter/node_modules/typescript/lib/fr/diagnosticMessages.generated.json +0 -2122
  144. package/skills/jitter/node_modules/typescript/lib/it/diagnosticMessages.generated.json +0 -2122
  145. package/skills/jitter/node_modules/typescript/lib/ja/diagnosticMessages.generated.json +0 -2122
  146. package/skills/jitter/node_modules/typescript/lib/ko/diagnosticMessages.generated.json +0 -2122
  147. package/skills/jitter/node_modules/typescript/lib/lib.d.ts +0 -22
  148. package/skills/jitter/node_modules/typescript/lib/lib.decorators.d.ts +0 -384
  149. package/skills/jitter/node_modules/typescript/lib/lib.decorators.legacy.d.ts +0 -22
  150. package/skills/jitter/node_modules/typescript/lib/lib.dom.asynciterable.d.ts +0 -41
  151. package/skills/jitter/node_modules/typescript/lib/lib.dom.d.ts +0 -39429
  152. package/skills/jitter/node_modules/typescript/lib/lib.dom.iterable.d.ts +0 -571
  153. package/skills/jitter/node_modules/typescript/lib/lib.es2015.collection.d.ts +0 -147
  154. package/skills/jitter/node_modules/typescript/lib/lib.es2015.core.d.ts +0 -597
  155. package/skills/jitter/node_modules/typescript/lib/lib.es2015.d.ts +0 -28
  156. package/skills/jitter/node_modules/typescript/lib/lib.es2015.generator.d.ts +0 -77
  157. package/skills/jitter/node_modules/typescript/lib/lib.es2015.iterable.d.ts +0 -605
  158. package/skills/jitter/node_modules/typescript/lib/lib.es2015.promise.d.ts +0 -81
  159. package/skills/jitter/node_modules/typescript/lib/lib.es2015.proxy.d.ts +0 -128
  160. package/skills/jitter/node_modules/typescript/lib/lib.es2015.reflect.d.ts +0 -144
  161. package/skills/jitter/node_modules/typescript/lib/lib.es2015.symbol.d.ts +0 -46
  162. package/skills/jitter/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts +0 -326
  163. package/skills/jitter/node_modules/typescript/lib/lib.es2016.array.include.d.ts +0 -116
  164. package/skills/jitter/node_modules/typescript/lib/lib.es2016.d.ts +0 -21
  165. package/skills/jitter/node_modules/typescript/lib/lib.es2016.full.d.ts +0 -23
  166. package/skills/jitter/node_modules/typescript/lib/lib.es2016.intl.d.ts +0 -31
  167. package/skills/jitter/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts +0 -21
  168. package/skills/jitter/node_modules/typescript/lib/lib.es2017.d.ts +0 -26
  169. package/skills/jitter/node_modules/typescript/lib/lib.es2017.date.d.ts +0 -31
  170. package/skills/jitter/node_modules/typescript/lib/lib.es2017.full.d.ts +0 -23
  171. package/skills/jitter/node_modules/typescript/lib/lib.es2017.intl.d.ts +0 -44
  172. package/skills/jitter/node_modules/typescript/lib/lib.es2017.object.d.ts +0 -49
  173. package/skills/jitter/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts +0 -135
  174. package/skills/jitter/node_modules/typescript/lib/lib.es2017.string.d.ts +0 -45
  175. package/skills/jitter/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts +0 -53
  176. package/skills/jitter/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts +0 -77
  177. package/skills/jitter/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts +0 -53
  178. package/skills/jitter/node_modules/typescript/lib/lib.es2018.d.ts +0 -24
  179. package/skills/jitter/node_modules/typescript/lib/lib.es2018.full.d.ts +0 -24
  180. package/skills/jitter/node_modules/typescript/lib/lib.es2018.intl.d.ts +0 -83
  181. package/skills/jitter/node_modules/typescript/lib/lib.es2018.promise.d.ts +0 -30
  182. package/skills/jitter/node_modules/typescript/lib/lib.es2018.regexp.d.ts +0 -37
  183. package/skills/jitter/node_modules/typescript/lib/lib.es2019.array.d.ts +0 -79
  184. package/skills/jitter/node_modules/typescript/lib/lib.es2019.d.ts +0 -24
  185. package/skills/jitter/node_modules/typescript/lib/lib.es2019.full.d.ts +0 -24
  186. package/skills/jitter/node_modules/typescript/lib/lib.es2019.intl.d.ts +0 -23
  187. package/skills/jitter/node_modules/typescript/lib/lib.es2019.object.d.ts +0 -33
  188. package/skills/jitter/node_modules/typescript/lib/lib.es2019.string.d.ts +0 -37
  189. package/skills/jitter/node_modules/typescript/lib/lib.es2019.symbol.d.ts +0 -24
  190. package/skills/jitter/node_modules/typescript/lib/lib.es2020.bigint.d.ts +0 -765
  191. package/skills/jitter/node_modules/typescript/lib/lib.es2020.d.ts +0 -27
  192. package/skills/jitter/node_modules/typescript/lib/lib.es2020.date.d.ts +0 -42
  193. package/skills/jitter/node_modules/typescript/lib/lib.es2020.full.d.ts +0 -24
  194. package/skills/jitter/node_modules/typescript/lib/lib.es2020.intl.d.ts +0 -474
  195. package/skills/jitter/node_modules/typescript/lib/lib.es2020.number.d.ts +0 -28
  196. package/skills/jitter/node_modules/typescript/lib/lib.es2020.promise.d.ts +0 -47
  197. package/skills/jitter/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts +0 -99
  198. package/skills/jitter/node_modules/typescript/lib/lib.es2020.string.d.ts +0 -44
  199. package/skills/jitter/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts +0 -41
  200. package/skills/jitter/node_modules/typescript/lib/lib.es2021.d.ts +0 -23
  201. package/skills/jitter/node_modules/typescript/lib/lib.es2021.full.d.ts +0 -24
  202. package/skills/jitter/node_modules/typescript/lib/lib.es2021.intl.d.ts +0 -166
  203. package/skills/jitter/node_modules/typescript/lib/lib.es2021.promise.d.ts +0 -48
  204. package/skills/jitter/node_modules/typescript/lib/lib.es2021.string.d.ts +0 -33
  205. package/skills/jitter/node_modules/typescript/lib/lib.es2021.weakref.d.ts +0 -78
  206. package/skills/jitter/node_modules/typescript/lib/lib.es2022.array.d.ts +0 -121
  207. package/skills/jitter/node_modules/typescript/lib/lib.es2022.d.ts +0 -25
  208. package/skills/jitter/node_modules/typescript/lib/lib.es2022.error.d.ts +0 -75
  209. package/skills/jitter/node_modules/typescript/lib/lib.es2022.full.d.ts +0 -24
  210. package/skills/jitter/node_modules/typescript/lib/lib.es2022.intl.d.ts +0 -145
  211. package/skills/jitter/node_modules/typescript/lib/lib.es2022.object.d.ts +0 -26
  212. package/skills/jitter/node_modules/typescript/lib/lib.es2022.regexp.d.ts +0 -39
  213. package/skills/jitter/node_modules/typescript/lib/lib.es2022.string.d.ts +0 -25
  214. package/skills/jitter/node_modules/typescript/lib/lib.es2023.array.d.ts +0 -924
  215. package/skills/jitter/node_modules/typescript/lib/lib.es2023.collection.d.ts +0 -21
  216. package/skills/jitter/node_modules/typescript/lib/lib.es2023.d.ts +0 -22
  217. package/skills/jitter/node_modules/typescript/lib/lib.es2023.full.d.ts +0 -24
  218. package/skills/jitter/node_modules/typescript/lib/lib.es2023.intl.d.ts +0 -56
  219. package/skills/jitter/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts +0 -65
  220. package/skills/jitter/node_modules/typescript/lib/lib.es2024.collection.d.ts +0 -29
  221. package/skills/jitter/node_modules/typescript/lib/lib.es2024.d.ts +0 -26
  222. package/skills/jitter/node_modules/typescript/lib/lib.es2024.full.d.ts +0 -24
  223. package/skills/jitter/node_modules/typescript/lib/lib.es2024.object.d.ts +0 -29
  224. package/skills/jitter/node_modules/typescript/lib/lib.es2024.promise.d.ts +0 -35
  225. package/skills/jitter/node_modules/typescript/lib/lib.es2024.regexp.d.ts +0 -25
  226. package/skills/jitter/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts +0 -68
  227. package/skills/jitter/node_modules/typescript/lib/lib.es2024.string.d.ts +0 -29
  228. package/skills/jitter/node_modules/typescript/lib/lib.es5.d.ts +0 -4601
  229. package/skills/jitter/node_modules/typescript/lib/lib.es6.d.ts +0 -23
  230. package/skills/jitter/node_modules/typescript/lib/lib.esnext.array.d.ts +0 -35
  231. package/skills/jitter/node_modules/typescript/lib/lib.esnext.collection.d.ts +0 -96
  232. package/skills/jitter/node_modules/typescript/lib/lib.esnext.d.ts +0 -29
  233. package/skills/jitter/node_modules/typescript/lib/lib.esnext.decorators.d.ts +0 -28
  234. package/skills/jitter/node_modules/typescript/lib/lib.esnext.disposable.d.ts +0 -193
  235. package/skills/jitter/node_modules/typescript/lib/lib.esnext.error.d.ts +0 -24
  236. package/skills/jitter/node_modules/typescript/lib/lib.esnext.float16.d.ts +0 -443
  237. package/skills/jitter/node_modules/typescript/lib/lib.esnext.full.d.ts +0 -24
  238. package/skills/jitter/node_modules/typescript/lib/lib.esnext.intl.d.ts +0 -21
  239. package/skills/jitter/node_modules/typescript/lib/lib.esnext.iterator.d.ts +0 -148
  240. package/skills/jitter/node_modules/typescript/lib/lib.esnext.promise.d.ts +0 -34
  241. package/skills/jitter/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts +0 -25
  242. package/skills/jitter/node_modules/typescript/lib/lib.scripthost.d.ts +0 -322
  243. package/skills/jitter/node_modules/typescript/lib/lib.webworker.asynciterable.d.ts +0 -41
  244. package/skills/jitter/node_modules/typescript/lib/lib.webworker.d.ts +0 -13150
  245. package/skills/jitter/node_modules/typescript/lib/lib.webworker.importscripts.d.ts +0 -23
  246. package/skills/jitter/node_modules/typescript/lib/lib.webworker.iterable.d.ts +0 -340
  247. package/skills/jitter/node_modules/typescript/lib/pl/diagnosticMessages.generated.json +0 -2122
  248. package/skills/jitter/node_modules/typescript/lib/pt-br/diagnosticMessages.generated.json +0 -2122
  249. package/skills/jitter/node_modules/typescript/lib/ru/diagnosticMessages.generated.json +0 -2122
  250. package/skills/jitter/node_modules/typescript/lib/tr/diagnosticMessages.generated.json +0 -2122
  251. package/skills/jitter/node_modules/typescript/lib/tsc.js +0 -8
  252. package/skills/jitter/node_modules/typescript/lib/tsserver.js +0 -8
  253. package/skills/jitter/node_modules/typescript/lib/tsserverlibrary.d.ts +0 -17
  254. package/skills/jitter/node_modules/typescript/lib/tsserverlibrary.js +0 -21
  255. package/skills/jitter/node_modules/typescript/lib/typesMap.json +0 -497
  256. package/skills/jitter/node_modules/typescript/lib/typescript.d.ts +0 -11438
  257. package/skills/jitter/node_modules/typescript/lib/typescript.js +0 -200253
  258. package/skills/jitter/node_modules/typescript/lib/typingsInstaller.js +0 -8
  259. package/skills/jitter/node_modules/typescript/lib/watchGuard.js +0 -53
  260. package/skills/jitter/node_modules/typescript/lib/zh-cn/diagnosticMessages.generated.json +0 -2122
  261. package/skills/jitter/node_modules/typescript/lib/zh-tw/diagnosticMessages.generated.json +0 -2122
  262. package/skills/jitter/node_modules/typescript/node_modules/.bin/tsc +0 -21
  263. package/skills/jitter/node_modules/typescript/node_modules/.bin/tsserver +0 -21
  264. package/skills/jitter/node_modules/typescript/package.json +0 -120
@@ -0,0 +1,515 @@
1
+ import fs from 'node:fs';
2
+ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
+ import { getChannelVerbosity, getPartMessageIds, getThreadIdBySessionId, getThreadSessionSource, listTrackedTextChannels, setPartMessagesBatch, upsertThreadSession, } from './database.js';
4
+ import { sendThreadMessage } from './discord-utils.js';
5
+ import { createLogger, LogPrefix } from './logger.js';
6
+ import { formatPart, collectSessionChunks, batchChunksForDiscord, } from './message-formatting.js';
7
+ import { initializeOpencodeForDirectory, } from './opencode.js';
8
+ import { isEssentialToolPart } from './session-handler/thread-session-runtime.js';
9
+ import { notifyError } from './sentry.js';
10
+ import { extractNonXmlContent } from './xml.js';
11
+ const logger = createLogger(LogPrefix.OPENCODE);
12
+ const EXTERNAL_SYNC_INTERVAL_MS = 5_000;
13
+ // Don't sync sessions from before the CLI started. 5 min grace window
14
+ // covers sessions that were just created before the bot connected.
15
+ const CLI_START_MS = Date.now() - 5 * 60 * 1000;
16
+ let externalSyncInterval = null;
17
+ function isSyntheticTextPart(part) {
18
+ const candidate = part;
19
+ return candidate.synthetic === true;
20
+ }
21
+ function parseDiscordOriginMetadata(text) {
22
+ const match = text.match(/^<discord-user\s+([^>]+)\s*\/>$/);
23
+ if (!match?.[1]) {
24
+ return null;
25
+ }
26
+ const attrs = [...match[1].matchAll(/([a-z-]+)="([^"]*)"/g)].reduce((acc, current) => {
27
+ const [, key, value] = current;
28
+ if (!key) {
29
+ return acc;
30
+ }
31
+ acc[key] = value || '';
32
+ return acc;
33
+ }, {});
34
+ const username = attrs['name'];
35
+ if (!username) {
36
+ return null;
37
+ }
38
+ return {
39
+ messageId: attrs['message-id'] || undefined,
40
+ username,
41
+ threadId: attrs['thread-id'] || undefined,
42
+ };
43
+ }
44
+ function getDiscordOriginMetadataFromMessage({ message, }) {
45
+ const syntheticTexts = message.parts.flatMap((part) => {
46
+ if (part.type !== 'text') {
47
+ return [];
48
+ }
49
+ if (!isSyntheticTextPart(part)) {
50
+ return [];
51
+ }
52
+ return [part.text || ''];
53
+ });
54
+ for (const text of syntheticTexts) {
55
+ const metadata = parseDiscordOriginMetadata(text);
56
+ if (metadata) {
57
+ return metadata;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ function getRenderableUserTextParts({ message, }) {
63
+ if (message.info.role !== 'user') {
64
+ return [];
65
+ }
66
+ return message.parts.flatMap((part) => {
67
+ if (part.type !== 'text') {
68
+ return [];
69
+ }
70
+ if (isSyntheticTextPart(part)) {
71
+ return [];
72
+ }
73
+ const cleanedText = extractNonXmlContent(part.text || '').trim();
74
+ if (!cleanedText) {
75
+ return [];
76
+ }
77
+ return [{ id: part.id, text: cleanedText }];
78
+ });
79
+ }
80
+ function getExternalUserMirrorText({ username, prompt, }) {
81
+ return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
82
+ }
83
+ // Pure derivation: is the latest user turn from Discord?
84
+ // Checks the newest user message with renderable text for a <discord-user />
85
+ // synthetic part. If present, the session is currently driven from Discord
86
+ // (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
87
+ // external sync should mirror it — this naturally handles the "reclaim" case
88
+ // (external → discord → external) without any DB source toggling.
89
+ function isLatestUserTurnFromDiscord({ messages, }) {
90
+ for (let i = messages.length - 1; i >= 0; i--) {
91
+ const message = messages[i];
92
+ if (message.info.role !== 'user') {
93
+ continue;
94
+ }
95
+ const renderableParts = getRenderableUserTextParts({ message });
96
+ if (renderableParts.length === 0) {
97
+ continue;
98
+ }
99
+ // Found the latest user message with actual text content.
100
+ // If it has <discord-user /> origin metadata, it came from Discord.
101
+ return getDiscordOriginMetadataFromMessage({ message }) !== null;
102
+ }
103
+ // No user messages with text — treat as external (allow sync).
104
+ return false;
105
+ }
106
+ function shouldMirrorAssistantPart({ part, verbosity, }) {
107
+ if (verbosity === 'text_only') {
108
+ return part.type === 'text';
109
+ }
110
+ if (verbosity === 'text_and_essential_tools') {
111
+ if (part.type === 'text') {
112
+ return true;
113
+ }
114
+ return isEssentialToolPart(part);
115
+ }
116
+ return true;
117
+ }
118
+ function getSessionThreadName({ sessionTitle, messages, }) {
119
+ const normalizedTitle = sessionTitle?.trim();
120
+ if (normalizedTitle) {
121
+ return normalizedTitle.slice(0, 100);
122
+ }
123
+ const firstUserMessage = messages.find((message) => {
124
+ return message.info.role === 'user';
125
+ });
126
+ const firstUserText = firstUserMessage
127
+ ? getRenderableUserTextParts({ message: firstUserMessage })
128
+ .map((part) => {
129
+ return part.text;
130
+ })
131
+ .join(' ')
132
+ .trim()
133
+ : '';
134
+ if (firstUserText) {
135
+ return firstUserText.slice(0, 100);
136
+ }
137
+ return 'opencode session';
138
+ }
139
+ function getSessionRecencyTimestamp(session) {
140
+ return session.time.updated || session.time.created || 0;
141
+ }
142
+ function sortSessionsByRecency(sessions) {
143
+ return [...sessions].sort((left, right) => {
144
+ return getSessionRecencyTimestamp(right) - getSessionRecencyTimestamp(left);
145
+ });
146
+ }
147
+ function groupTrackedChannelsByDirectory(trackedChannels) {
148
+ const grouped = trackedChannels.reduce((acc, channel) => {
149
+ const existing = acc.get(channel.directory);
150
+ const createdAtMs = Math.max(channel.created_at?.getTime() || 0, CLI_START_MS);
151
+ if (!existing) {
152
+ acc.set(channel.directory, {
153
+ directory: channel.directory,
154
+ channelId: channel.channel_id,
155
+ startMs: createdAtMs,
156
+ });
157
+ return acc;
158
+ }
159
+ if (createdAtMs < existing.startMs) {
160
+ acc.set(channel.directory, {
161
+ directory: channel.directory,
162
+ channelId: channel.channel_id,
163
+ startMs: createdAtMs,
164
+ });
165
+ }
166
+ return acc;
167
+ }, new Map());
168
+ return [...grouped.values()];
169
+ }
170
+ async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages, }) {
171
+ const existingThreadId = await getThreadIdBySessionId(sessionId);
172
+ if (existingThreadId) {
173
+ // Caller already verified via isLatestUserTurnFromDiscord that this
174
+ // session should be synced. If the thread was kimaki-owned, flip it
175
+ // to external_poll so typing and future polls work naturally.
176
+ const existingSource = await getThreadSessionSource(existingThreadId);
177
+ if (existingSource === 'kimaki') {
178
+ await upsertThreadSession({
179
+ threadId: existingThreadId,
180
+ sessionId,
181
+ source: 'external_poll',
182
+ });
183
+ logger.log(`[EXTERNAL_SYNC] Reclaimed thread ${existingThreadId} for session ${sessionId} (user resumed from OpenCode)`);
184
+ }
185
+ const existingThread = await discordClient.channels.fetch(existingThreadId).catch((error) => {
186
+ return new Error(`Failed to fetch thread ${existingThreadId}`, {
187
+ cause: error,
188
+ });
189
+ });
190
+ if (!(existingThread instanceof Error) && existingThread?.isThread()) {
191
+ return existingThread;
192
+ }
193
+ }
194
+ const parentChannel = await discordClient.channels.fetch(channelId).catch((error) => {
195
+ return new Error(`Failed to fetch parent channel ${channelId}`, {
196
+ cause: error,
197
+ });
198
+ });
199
+ if (parentChannel instanceof Error) {
200
+ return parentChannel;
201
+ }
202
+ if (!parentChannel || parentChannel.type !== ChannelType.GuildText) {
203
+ return new Error(`Channel ${channelId} is not a text channel`);
204
+ }
205
+ const threadName = 'Sync: ' + getSessionThreadName({ sessionTitle, messages });
206
+ const thread = await parentChannel.threads.create({
207
+ name: threadName.slice(0, 100),
208
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
209
+ reason: `Sync external OpenCode session ${sessionId}`,
210
+ }).catch((error) => {
211
+ return new Error(`Failed to create thread for session ${sessionId}`, {
212
+ cause: error,
213
+ });
214
+ });
215
+ if (thread instanceof Error) {
216
+ return thread;
217
+ }
218
+ await upsertThreadSession({
219
+ threadId: thread.id,
220
+ sessionId,
221
+ source: 'external_poll',
222
+ });
223
+ return thread;
224
+ }
225
+ // Collect all unsynced parts from all messages into SessionChunks.
226
+ // User messages that originated from this Discord thread are returned as
227
+ // directMappings (persisted without sending a Discord message). All other
228
+ // user and assistant parts are returned as chunks to send.
229
+ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, }) {
230
+ const chunks = [];
231
+ const directMappings = [];
232
+ for (const message of messages) {
233
+ if (message.info.role === 'user') {
234
+ const renderableParts = getRenderableUserTextParts({ message });
235
+ const unsyncedParts = renderableParts.filter((p) => {
236
+ return !syncedPartIds.has(p.id);
237
+ });
238
+ if (unsyncedParts.length === 0) {
239
+ continue;
240
+ }
241
+ // If the user message came from this Discord thread, skip mirroring
242
+ // — it's already visible. When message-id is available, record a
243
+ // direct mapping for part dedup. When it's missing (sourceMessageId
244
+ // is optional in IngressInput), just mark parts as synced.
245
+ const discordOrigin = getDiscordOriginMetadataFromMessage({ message });
246
+ if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
247
+ unsyncedParts.forEach((part) => {
248
+ directMappings.push({
249
+ partId: part.id,
250
+ messageId: discordOrigin.messageId || '',
251
+ threadId: thread.id,
252
+ });
253
+ syncedPartIds.add(part.id);
254
+ });
255
+ continue;
256
+ }
257
+ const promptText = unsyncedParts.map((p) => {
258
+ return p.text;
259
+ }).join('\n\n');
260
+ chunks.push({
261
+ partIds: unsyncedParts.map((p) => {
262
+ return p.id;
263
+ }),
264
+ content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
265
+ });
266
+ continue;
267
+ }
268
+ if (message.info.role !== 'assistant') {
269
+ continue;
270
+ }
271
+ // Filter assistant parts by verbosity before passing to shared collector
272
+ const filteredParts = message.parts.filter((part) => {
273
+ return shouldMirrorAssistantPart({ part, verbosity });
274
+ });
275
+ const { chunks: assistantChunks } = collectSessionChunks({
276
+ messages: [{ info: message.info, parts: filteredParts }],
277
+ skipPartIds: syncedPartIds,
278
+ });
279
+ // Mark empty-content parts as synced (collectSessionChunks skips them)
280
+ for (const part of filteredParts) {
281
+ if (!syncedPartIds.has(part.id)) {
282
+ const content = formatPart(part);
283
+ if (!content.trim()) {
284
+ syncedPartIds.add(part.id);
285
+ }
286
+ }
287
+ }
288
+ chunks.push(...assistantChunks);
289
+ }
290
+ return { chunks, directMappings };
291
+ }
292
+ async function syncSessionToThread({ client, discordClient, directory, channelId, sessionId, sessionTitle, }) {
293
+ const messagesResponse = await client.session.messages({
294
+ sessionID: sessionId,
295
+ directory,
296
+ }).catch((error) => {
297
+ return new Error(`Failed to fetch messages for session ${sessionId}`, {
298
+ cause: error,
299
+ });
300
+ });
301
+ if (messagesResponse instanceof Error) {
302
+ throw messagesResponse;
303
+ }
304
+ const messages = messagesResponse.data || [];
305
+ // Pure derivation from opencode events: if the latest user turn has
306
+ // <discord-user /> metadata, kimaki's thread runtime owns this session.
307
+ // Skip external sync entirely. When the user resumes from CLI/TUI the
308
+ // latest user turn will lack the tag, so sync picks it up naturally.
309
+ if (isLatestUserTurnFromDiscord({ messages })) {
310
+ return;
311
+ }
312
+ const thread = await ensureExternalSessionThread({
313
+ discordClient,
314
+ channelId,
315
+ sessionId,
316
+ sessionTitle,
317
+ messages,
318
+ });
319
+ if (thread === null) {
320
+ return;
321
+ }
322
+ if (thread instanceof Error) {
323
+ throw thread;
324
+ }
325
+ const [existingPartIds, verbosity] = await Promise.all([
326
+ getPartMessageIds(thread.id),
327
+ getChannelVerbosity(thread.parentId || thread.id),
328
+ ]);
329
+ const syncedPartIds = new Set(existingPartIds);
330
+ const { chunks, directMappings } = collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread });
331
+ // Persist mappings for user parts that originated from this Discord thread
332
+ if (directMappings.length > 0) {
333
+ await setPartMessagesBatch(directMappings);
334
+ }
335
+ const batched = batchChunksForDiscord(chunks);
336
+ for (const batch of batched) {
337
+ const sentMessage = await sendThreadMessage(thread, batch.content);
338
+ await setPartMessagesBatch(batch.partIds.map((partId) => ({
339
+ partId,
340
+ messageId: sentMessage.id,
341
+ threadId: thread.id,
342
+ })));
343
+ }
344
+ }
345
+ // Pulse typing indicator for sessions that are currently busy.
346
+ // Takes the global session statuses map (already fetched) and sends
347
+ // typing to threads whose session is busy and still managed by external_poll.
348
+ async function pulseTypingForBusySessions({ discordClient, statuses, }) {
349
+ for (const [sessionId, status] of Object.entries(statuses)) {
350
+ if (status.type !== 'busy') {
351
+ continue;
352
+ }
353
+ const threadId = await getThreadIdBySessionId(sessionId);
354
+ if (!threadId) {
355
+ continue;
356
+ }
357
+ // Skip sessions already managed by the runtime (source='kimaki')
358
+ const source = await getThreadSessionSource(threadId);
359
+ if (source && source !== 'external_poll') {
360
+ continue;
361
+ }
362
+ const thread = await discordClient.channels.fetch(threadId).catch(() => {
363
+ return null;
364
+ });
365
+ if (thread?.isThread()) {
366
+ await thread.sendTyping().catch(() => { });
367
+ }
368
+ }
369
+ }
370
+ // Use experimental.session.list (global, all directories) to reduce from
371
+ // N*2 HTTP calls to 1 global list + per-active-directory status calls.
372
+ async function pollExternalSessions({ discordClient, }) {
373
+ const trackedChannels = await listTrackedTextChannels();
374
+ const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
375
+ .filter((t) => {
376
+ return fs.existsSync(t.directory);
377
+ });
378
+ if (directoryTargets.length === 0) {
379
+ return;
380
+ }
381
+ // Build a lookup: directory → { channelId, startMs }
382
+ const directoryMap = new Map();
383
+ for (const target of directoryTargets) {
384
+ directoryMap.set(target.directory, {
385
+ channelId: target.channelId,
386
+ startMs: target.startMs,
387
+ });
388
+ }
389
+ // Use earliest startMs across all directories for the global query
390
+ const globalStartMs = Math.min(...directoryTargets.map((t) => {
391
+ return t.startMs;
392
+ }));
393
+ // Get one opencode client — try each existing directory until one succeeds
394
+ let client;
395
+ for (const target of directoryTargets) {
396
+ const result = await initializeOpencodeForDirectory(target.directory, {
397
+ channelId: target.channelId,
398
+ });
399
+ if (!(result instanceof Error)) {
400
+ client = result();
401
+ break;
402
+ }
403
+ }
404
+ if (!client) {
405
+ return;
406
+ }
407
+ // One global API call for all sessions across all directories.
408
+ // Results are sorted by most recently updated, so a fixed limit of 50
409
+ // is enough — we always get the most active sessions first.
410
+ const sessionsResponse = await client.experimental.session.list({
411
+ roots: true,
412
+ start: globalStartMs,
413
+ limit: 50,
414
+ }).catch((error) => {
415
+ return new Error('Failed to list global sessions', { cause: error });
416
+ });
417
+ if (sessionsResponse instanceof Error) {
418
+ logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
419
+ return;
420
+ }
421
+ const allSessions = sessionsResponse.data || [];
422
+ // Group sessions by directory, filtering to tracked directories only
423
+ const sessionsByDirectory = new Map();
424
+ for (const session of allSessions) {
425
+ const target = directoryMap.get(session.directory);
426
+ if (!target) {
427
+ continue;
428
+ }
429
+ // Filter by per-directory startMs (time.updated or time.created)
430
+ if ((session.time.updated || session.time.created || 0) < target.startMs) {
431
+ continue;
432
+ }
433
+ // Skip sessions whose title hasn't been generated yet
434
+ if (/^new session\s*-/i.test(session.title || '')) {
435
+ continue;
436
+ }
437
+ const existing = sessionsByDirectory.get(session.directory) || [];
438
+ existing.push(session);
439
+ sessionsByDirectory.set(session.directory, existing);
440
+ }
441
+ // Fetch session.status() only for directories that have sessions to sync.
442
+ // session.status() is instance-scoped (uses x-opencode-directory header),
443
+ // so we must call it per directory — but only for active ones, not all 30+.
444
+ const activeDirectories = [...sessionsByDirectory.keys()];
445
+ const statusResults = await Promise.all(activeDirectories.map(async (directory) => {
446
+ const res = await client.session.status({ directory }).catch(() => {
447
+ return null;
448
+ });
449
+ return res?.data ? Object.entries(res.data) : [];
450
+ }));
451
+ const mergedStatuses = Object.fromEntries(statusResults.flat());
452
+ // Pulse typing for busy sessions
453
+ await pulseTypingForBusySessions({ discordClient, statuses: mergedStatuses }).catch(() => { });
454
+ for (const [directory, sessions] of sessionsByDirectory) {
455
+ const target = directoryMap.get(directory);
456
+ const sorted = sortSessionsByRecency(sessions);
457
+ logger.log(`[EXTERNAL_SYNC] ${directory}: ${sorted.length} sessions to sync`);
458
+ for (const session of sorted) {
459
+ await syncSessionToThread({
460
+ client,
461
+ discordClient,
462
+ directory,
463
+ channelId: target.channelId,
464
+ sessionId: session.id,
465
+ sessionTitle: session.title,
466
+ }).catch((error) => {
467
+ logger.warn(`[EXTERNAL_SYNC] Failed syncing session ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
468
+ void notifyError(error instanceof Error ? error : new Error(String(error)), `External session sync failed for ${session.id}`);
469
+ });
470
+ }
471
+ }
472
+ }
473
+ export function startExternalOpencodeSessionSync({ discordClient, }) {
474
+ if (process.env.KIMAKI_VITEST &&
475
+ process.env.KIMAKI_ENABLE_EXTERNAL_OPENCODE_SYNC !== '1') {
476
+ return;
477
+ }
478
+ if (externalSyncInterval) {
479
+ return;
480
+ }
481
+ logger.log(`[EXTERNAL_SYNC] started, polling every ${EXTERNAL_SYNC_INTERVAL_MS}ms`);
482
+ let polling = false;
483
+ const runPoll = async () => {
484
+ if (polling) {
485
+ return;
486
+ }
487
+ polling = true;
488
+ const result = await pollExternalSessions({ discordClient }).catch((e) => new Error('External session poll failed', { cause: e }));
489
+ polling = false;
490
+ if (result instanceof Error) {
491
+ logger.warn(`[EXTERNAL_SYNC] ${result.message}`);
492
+ void notifyError(result, 'External session poll top-level failure');
493
+ }
494
+ };
495
+ void runPoll();
496
+ externalSyncInterval = setInterval(() => {
497
+ void runPoll();
498
+ }, EXTERNAL_SYNC_INTERVAL_MS);
499
+ }
500
+ export function stopExternalOpencodeSessionSync() {
501
+ if (!externalSyncInterval) {
502
+ return;
503
+ }
504
+ clearInterval(externalSyncInterval);
505
+ externalSyncInterval = null;
506
+ }
507
+ export const externalOpencodeSyncInternals = {
508
+ getRenderableUserTextParts,
509
+ getSessionThreadName,
510
+ groupTrackedChannelsByDirectory,
511
+ sortSessionsByRecency,
512
+ parseDiscordOriginMetadata,
513
+ getDiscordOriginMetadataFromMessage,
514
+ isLatestUserTurnFromDiscord,
515
+ };
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { externalOpencodeSyncInternals } from './external-opencode-sync.js';
3
+ describe('externalOpencodeSyncInternals', () => {
4
+ test('getRenderableUserTextParts strips synthetic and xml-only text parts', () => {
5
+ const message = {
6
+ info: { role: 'user' },
7
+ parts: [
8
+ {
9
+ id: 'synthetic',
10
+ type: 'text',
11
+ sessionID: 'ses_1',
12
+ messageID: 'msg_1',
13
+ time: { start: 1 },
14
+ text: '<discord-user name="Tommy" message-id="discord-1" thread-id="thread-1" />',
15
+ synthetic: true,
16
+ },
17
+ {
18
+ id: 'xml-only',
19
+ type: 'text',
20
+ sessionID: 'ses_1',
21
+ messageID: 'msg_1',
22
+ time: { start: 2 },
23
+ text: '<discord-user name="Tommy" />',
24
+ },
25
+ {
26
+ id: 'visible',
27
+ type: 'text',
28
+ sessionID: 'ses_1',
29
+ messageID: 'msg_1',
30
+ time: { start: 3 },
31
+ text: 'hello\n<discord-user name="Tommy" />\nworld',
32
+ },
33
+ ],
34
+ };
35
+ expect(externalOpencodeSyncInternals.getRenderableUserTextParts({ message })).toMatchInlineSnapshot(`
36
+ [
37
+ {
38
+ "id": "visible",
39
+ "text": "hello
40
+ world",
41
+ },
42
+ ]
43
+ `);
44
+ });
45
+ test('getDiscordOriginMetadataFromMessage returns metadata from synthetic part', () => {
46
+ const message = {
47
+ info: { role: 'user' },
48
+ parts: [
49
+ {
50
+ id: 'visible',
51
+ type: 'text',
52
+ sessionID: 'ses_1',
53
+ messageID: 'msg_1',
54
+ time: { start: 1 },
55
+ text: 'hello from discord',
56
+ },
57
+ {
58
+ id: 'synthetic',
59
+ type: 'text',
60
+ sessionID: 'ses_1',
61
+ messageID: 'msg_1',
62
+ time: { start: 2 },
63
+ text: '<discord-user name="Tommy" message-id="discord-1" thread-id="thread-1" />',
64
+ synthetic: true,
65
+ },
66
+ ],
67
+ };
68
+ expect(externalOpencodeSyncInternals.getDiscordOriginMetadataFromMessage({
69
+ message,
70
+ })).toMatchInlineSnapshot(`
71
+ {
72
+ "messageId": "discord-1",
73
+ "threadId": "thread-1",
74
+ "username": "Tommy",
75
+ }
76
+ `);
77
+ });
78
+ test('groupTrackedChannelsByDirectory uses earliest created channel as cutoff', () => {
79
+ const grouped = externalOpencodeSyncInternals.groupTrackedChannelsByDirectory([
80
+ {
81
+ channel_id: 'channel-2',
82
+ directory: '/repo-a',
83
+ created_at: new Date('2026-03-25T12:00:00.000Z'),
84
+ },
85
+ {
86
+ channel_id: 'channel-1',
87
+ directory: '/repo-a',
88
+ created_at: new Date('2026-03-25T10:00:00.000Z'),
89
+ },
90
+ {
91
+ channel_id: 'channel-3',
92
+ directory: '/repo-b',
93
+ created_at: null,
94
+ },
95
+ ]);
96
+ expect(grouped).toMatchInlineSnapshot(`
97
+ [
98
+ {
99
+ "channelId": "channel-1",
100
+ "directory": "/repo-a",
101
+ "startMs": 1774432800000,
102
+ },
103
+ {
104
+ "channelId": "channel-3",
105
+ "directory": "/repo-b",
106
+ "startMs": 0,
107
+ },
108
+ ]
109
+ `);
110
+ });
111
+ test('sortSessionsByRecency orders sessions by latest updated time first', () => {
112
+ const sorted = externalOpencodeSyncInternals.sortSessionsByRecency([
113
+ {
114
+ id: 'session-older',
115
+ title: 'older',
116
+ slug: 'session-older',
117
+ projectID: 'project-1',
118
+ directory: '/repo-a',
119
+ version: '1',
120
+ time: { created: 10, updated: 20 },
121
+ },
122
+ {
123
+ id: 'session-newest',
124
+ title: 'newest',
125
+ slug: 'session-newest',
126
+ projectID: 'project-1',
127
+ directory: '/repo-a',
128
+ version: '1',
129
+ time: { created: 30, updated: 40 },
130
+ },
131
+ {
132
+ id: 'session-no-update',
133
+ title: 'no-update',
134
+ slug: 'session-no-update',
135
+ projectID: 'project-1',
136
+ directory: '/repo-a',
137
+ version: '1',
138
+ time: { created: 35, updated: 0 },
139
+ },
140
+ ]);
141
+ expect(sorted.map((session) => {
142
+ return session.id;
143
+ })).toMatchInlineSnapshot(`
144
+ [
145
+ "session-newest",
146
+ "session-no-update",
147
+ "session-older",
148
+ ]
149
+ `);
150
+ });
151
+ });