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,729 @@
1
+ import fs from 'node:fs'
2
+ import {
3
+ ChannelType,
4
+ ThreadAutoArchiveDuration,
5
+ type Client,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import type {
10
+ OpencodeClient,
11
+ Part,
12
+ } from '@opencode-ai/sdk/v2'
13
+ import {
14
+ getChannelVerbosity,
15
+ getPartMessageIds,
16
+ getThreadIdBySessionId,
17
+ getThreadSessionSource,
18
+ listTrackedTextChannels,
19
+ setPartMessagesBatch,
20
+ upsertThreadSession,
21
+ } from './database.js'
22
+ import { sendThreadMessage } from './discord-utils.js'
23
+ import { createLogger, LogPrefix } from './logger.js'
24
+ import {
25
+ formatPart,
26
+ collectSessionChunks,
27
+ batchChunksForDiscord,
28
+ type SessionChunk,
29
+ } from './message-formatting.js'
30
+ import {
31
+ initializeOpencodeForDirectory,
32
+ } from './opencode.js'
33
+ import { isEssentialToolPart } from './session-handler/thread-session-runtime.js'
34
+ import { notifyError } from './sentry.js'
35
+ import { extractNonXmlContent } from './xml.js'
36
+
37
+
38
+ const logger = createLogger(LogPrefix.OPENCODE)
39
+
40
+ const EXTERNAL_SYNC_INTERVAL_MS = 5_000
41
+ // Don't sync sessions from before the CLI started. 5 min grace window
42
+ // covers sessions that were just created before the bot connected.
43
+ const CLI_START_MS = Date.now() - 5 * 60 * 1000
44
+
45
+ type RenderableUserTextPart = {
46
+ id: string
47
+ text: string
48
+ }
49
+
50
+ type SessionMessagesResponse = Awaited<
51
+ ReturnType<OpencodeClient['session']['messages']>
52
+ >
53
+ type SessionMessage = NonNullable<SessionMessagesResponse['data']>[number]
54
+ type SessionMessageLike = {
55
+ info: {
56
+ role: string
57
+ }
58
+ parts: Part[]
59
+ }
60
+
61
+ type DiscordOriginMetadata = {
62
+ messageId?: string
63
+ username: string
64
+ threadId?: string
65
+ }
66
+
67
+ type TrackedTextChannelRow = Awaited<ReturnType<typeof listTrackedTextChannels>>[number]
68
+
69
+ type DirectorySyncTarget = {
70
+ directory: string
71
+ channelId: string
72
+ startMs: number
73
+ }
74
+
75
+ type GlobalListedSession = NonNullable<
76
+ Awaited<ReturnType<OpencodeClient['experimental']['session']['list']>>['data']
77
+ >[number]
78
+
79
+ let externalSyncInterval: ReturnType<typeof setInterval> | null = null
80
+
81
+ function isSyntheticTextPart(part: Extract<Part, { type: 'text' }>): boolean {
82
+ const candidate = part as Extract<Part, { type: 'text' }> & {
83
+ synthetic?: unknown
84
+ }
85
+ return candidate.synthetic === true
86
+ }
87
+
88
+ function parseDiscordOriginMetadata(text: string): DiscordOriginMetadata | null {
89
+ const match = text.match(/^<discord-user\s+([^>]+)\s*\/>$/)
90
+ if (!match?.[1]) {
91
+ return null
92
+ }
93
+ const attrs = [...match[1].matchAll(/([a-z-]+)="([^"]*)"/g)].reduce(
94
+ (acc, current) => {
95
+ const [, key, value] = current
96
+ if (!key) {
97
+ return acc
98
+ }
99
+ acc[key] = value || ''
100
+ return acc
101
+ },
102
+ {} as Record<string, string>,
103
+ )
104
+ const username = attrs['name']
105
+ if (!username) {
106
+ return null
107
+ }
108
+ return {
109
+ messageId: attrs['message-id'] || undefined,
110
+ username,
111
+ threadId: attrs['thread-id'] || undefined,
112
+ }
113
+ }
114
+
115
+ function getDiscordOriginMetadataFromMessage({
116
+ message,
117
+ }: {
118
+ message: SessionMessageLike
119
+ }): DiscordOriginMetadata | null {
120
+ const syntheticTexts = message.parts.flatMap((part) => {
121
+ if (part.type !== 'text') {
122
+ return [] as string[]
123
+ }
124
+ if (!isSyntheticTextPart(part)) {
125
+ return [] as string[]
126
+ }
127
+ return [part.text || '']
128
+ })
129
+
130
+ for (const text of syntheticTexts) {
131
+ const metadata = parseDiscordOriginMetadata(text)
132
+ if (metadata) {
133
+ return metadata
134
+ }
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ function getRenderableUserTextParts({
141
+ message,
142
+ }: {
143
+ message: SessionMessageLike
144
+ }): RenderableUserTextPart[] {
145
+ if (message.info.role !== 'user') {
146
+ return []
147
+ }
148
+
149
+ return message.parts.flatMap((part) => {
150
+ if (part.type !== 'text') {
151
+ return [] as RenderableUserTextPart[]
152
+ }
153
+ if (isSyntheticTextPart(part)) {
154
+ return [] as RenderableUserTextPart[]
155
+ }
156
+ const cleanedText = extractNonXmlContent(part.text || '').trim()
157
+ if (!cleanedText) {
158
+ return [] as RenderableUserTextPart[]
159
+ }
160
+ return [{ id: part.id, text: cleanedText }]
161
+ })
162
+ }
163
+
164
+ function getExternalUserMirrorText({
165
+ username,
166
+ prompt,
167
+ }: {
168
+ username: string
169
+ prompt: string
170
+ }): string {
171
+ return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`
172
+ }
173
+
174
+ // Pure derivation: is the latest user turn from Discord?
175
+ // Checks the newest user message with renderable text for a <discord-user />
176
+ // synthetic part. If present, the session is currently driven from Discord
177
+ // (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
178
+ // external sync should mirror it — this naturally handles the "reclaim" case
179
+ // (external → discord → external) without any DB source toggling.
180
+ function isLatestUserTurnFromDiscord({
181
+ messages,
182
+ }: {
183
+ messages: SessionMessageLike[]
184
+ }): boolean {
185
+ for (let i = messages.length - 1; i >= 0; i--) {
186
+ const message = messages[i]!
187
+ if (message.info.role !== 'user') {
188
+ continue
189
+ }
190
+ const renderableParts = getRenderableUserTextParts({ message })
191
+ if (renderableParts.length === 0) {
192
+ continue
193
+ }
194
+ // Found the latest user message with actual text content.
195
+ // If it has <discord-user /> origin metadata, it came from Discord.
196
+ return getDiscordOriginMetadataFromMessage({ message }) !== null
197
+ }
198
+ // No user messages with text — treat as external (allow sync).
199
+ return false
200
+ }
201
+
202
+ function shouldMirrorAssistantPart({
203
+ part,
204
+ verbosity,
205
+ }: {
206
+ part: Part
207
+ verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
208
+ }): boolean {
209
+ if (verbosity === 'text_only') {
210
+ return part.type === 'text'
211
+ }
212
+ if (verbosity === 'text_and_essential_tools') {
213
+ if (part.type === 'text') {
214
+ return true
215
+ }
216
+ return isEssentialToolPart(part)
217
+ }
218
+ return true
219
+ }
220
+
221
+ function getSessionThreadName({
222
+ sessionTitle,
223
+ messages,
224
+ }: {
225
+ sessionTitle?: string | null
226
+ messages: SessionMessageLike[]
227
+ }): string {
228
+ const normalizedTitle = sessionTitle?.trim()
229
+ if (normalizedTitle) {
230
+ return normalizedTitle.slice(0, 100)
231
+ }
232
+ const firstUserMessage = messages.find((message) => {
233
+ return message.info.role === 'user'
234
+ })
235
+ const firstUserText = firstUserMessage
236
+ ? getRenderableUserTextParts({ message: firstUserMessage })
237
+ .map((part) => {
238
+ return part.text
239
+ })
240
+ .join(' ')
241
+ .trim()
242
+ : ''
243
+ if (firstUserText) {
244
+ return firstUserText.slice(0, 100)
245
+ }
246
+ return 'opencode session'
247
+ }
248
+
249
+ type SessionWithTime = { time: { created: number; updated: number } }
250
+
251
+ function getSessionRecencyTimestamp(session: SessionWithTime): number {
252
+ return session.time.updated || session.time.created || 0
253
+ }
254
+
255
+ function sortSessionsByRecency<T extends SessionWithTime>(sessions: T[]): T[] {
256
+ return [...sessions].sort((left, right) => {
257
+ return getSessionRecencyTimestamp(right) - getSessionRecencyTimestamp(left)
258
+ })
259
+ }
260
+
261
+ function groupTrackedChannelsByDirectory(
262
+ trackedChannels: TrackedTextChannelRow[],
263
+ ): DirectorySyncTarget[] {
264
+ const grouped = trackedChannels.reduce((acc, channel) => {
265
+ const existing = acc.get(channel.directory)
266
+ const createdAtMs = Math.max(channel.created_at?.getTime() || 0, CLI_START_MS)
267
+ if (!existing) {
268
+ acc.set(channel.directory, {
269
+ directory: channel.directory,
270
+ channelId: channel.channel_id,
271
+ startMs: createdAtMs,
272
+ })
273
+ return acc
274
+ }
275
+ if (createdAtMs < existing.startMs) {
276
+ acc.set(channel.directory, {
277
+ directory: channel.directory,
278
+ channelId: channel.channel_id,
279
+ startMs: createdAtMs,
280
+ })
281
+ }
282
+ return acc
283
+ }, new Map<string, DirectorySyncTarget>())
284
+ return [...grouped.values()]
285
+ }
286
+
287
+ async function ensureExternalSessionThread({
288
+ discordClient,
289
+ channelId,
290
+ sessionId,
291
+ sessionTitle,
292
+ messages,
293
+ }: {
294
+ discordClient: Client
295
+ channelId: string
296
+ sessionId: string
297
+ sessionTitle?: string | null
298
+ messages: SessionMessage[]
299
+ }): Promise<ThreadChannel | Error | null> {
300
+ const existingThreadId = await getThreadIdBySessionId(sessionId)
301
+ if (existingThreadId) {
302
+ // Caller already verified via isLatestUserTurnFromDiscord that this
303
+ // session should be synced. If the thread was kimaki-owned, flip it
304
+ // to external_poll so typing and future polls work naturally.
305
+ const existingSource = await getThreadSessionSource(existingThreadId)
306
+ if (existingSource === 'kimaki') {
307
+ await upsertThreadSession({
308
+ threadId: existingThreadId,
309
+ sessionId,
310
+ source: 'external_poll',
311
+ })
312
+ logger.log(`[EXTERNAL_SYNC] Reclaimed thread ${existingThreadId} for session ${sessionId} (user resumed from OpenCode)`)
313
+ }
314
+ const existingThread = await discordClient.channels.fetch(existingThreadId).catch((error) => {
315
+ return new Error(`Failed to fetch thread ${existingThreadId}`, {
316
+ cause: error,
317
+ })
318
+ })
319
+ if (!(existingThread instanceof Error) && existingThread?.isThread()) {
320
+ return existingThread
321
+ }
322
+ }
323
+
324
+ const parentChannel = await discordClient.channels.fetch(channelId).catch((error) => {
325
+ return new Error(`Failed to fetch parent channel ${channelId}`, {
326
+ cause: error,
327
+ })
328
+ })
329
+ if (parentChannel instanceof Error) {
330
+ return parentChannel
331
+ }
332
+ if (!parentChannel || parentChannel.type !== ChannelType.GuildText) {
333
+ return new Error(`Channel ${channelId} is not a text channel`)
334
+ }
335
+
336
+ const threadName = 'Sync: ' + getSessionThreadName({ sessionTitle, messages })
337
+ const thread = await (parentChannel as TextChannel).threads.create({
338
+ name: threadName.slice(0, 100),
339
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
340
+ reason: `Sync external OpenCode session ${sessionId}`,
341
+ }).catch((error) => {
342
+ return new Error(`Failed to create thread for session ${sessionId}`, {
343
+ cause: error,
344
+ })
345
+ })
346
+ if (thread instanceof Error) {
347
+ return thread
348
+ }
349
+
350
+ await upsertThreadSession({
351
+ threadId: thread.id,
352
+ sessionId,
353
+ source: 'external_poll',
354
+ })
355
+
356
+ return thread
357
+ }
358
+
359
+ type DirectPartMapping = { partId: string; messageId: string; threadId: string }
360
+
361
+ // Collect all unsynced parts from all messages into SessionChunks.
362
+ // User messages that originated from this Discord thread are returned as
363
+ // directMappings (persisted without sending a Discord message). All other
364
+ // user and assistant parts are returned as chunks to send.
365
+ function collectUnsyncedChunks({
366
+ messages,
367
+ syncedPartIds,
368
+ verbosity,
369
+ thread,
370
+ }: {
371
+ messages: SessionMessage[]
372
+ syncedPartIds: Set<string>
373
+ verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
374
+ thread: ThreadChannel
375
+ }): { chunks: SessionChunk[]; directMappings: DirectPartMapping[] } {
376
+ const chunks: SessionChunk[] = []
377
+ const directMappings: DirectPartMapping[] = []
378
+
379
+ for (const message of messages) {
380
+ if (message.info.role === 'user') {
381
+ const renderableParts = getRenderableUserTextParts({ message })
382
+ const unsyncedParts = renderableParts.filter((p) => {
383
+ return !syncedPartIds.has(p.id)
384
+ })
385
+ if (unsyncedParts.length === 0) {
386
+ continue
387
+ }
388
+ // If the user message came from this Discord thread, skip mirroring
389
+ // — it's already visible. When message-id is available, record a
390
+ // direct mapping for part dedup. When it's missing (sourceMessageId
391
+ // is optional in IngressInput), just mark parts as synced.
392
+ const discordOrigin = getDiscordOriginMetadataFromMessage({ message })
393
+ if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
394
+ unsyncedParts.forEach((part) => {
395
+ directMappings.push({
396
+ partId: part.id,
397
+ messageId: discordOrigin.messageId || '',
398
+ threadId: thread.id,
399
+ })
400
+ syncedPartIds.add(part.id)
401
+ })
402
+ continue
403
+ }
404
+ const promptText = unsyncedParts.map((p) => {
405
+ return p.text
406
+ }).join('\n\n')
407
+ chunks.push({
408
+ partIds: unsyncedParts.map((p) => {
409
+ return p.id
410
+ }),
411
+ content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
412
+ })
413
+ continue
414
+ }
415
+
416
+ if (message.info.role !== 'assistant') {
417
+ continue
418
+ }
419
+ // Filter assistant parts by verbosity before passing to shared collector
420
+ const filteredParts = message.parts.filter((part) => {
421
+ return shouldMirrorAssistantPart({ part, verbosity })
422
+ })
423
+ const { chunks: assistantChunks } = collectSessionChunks({
424
+ messages: [{ info: message.info, parts: filteredParts }],
425
+ skipPartIds: syncedPartIds,
426
+ })
427
+ // Mark empty-content parts as synced (collectSessionChunks skips them)
428
+ for (const part of filteredParts) {
429
+ if (!syncedPartIds.has(part.id)) {
430
+ const content = formatPart(part)
431
+ if (!content.trim()) {
432
+ syncedPartIds.add(part.id)
433
+ }
434
+ }
435
+ }
436
+ chunks.push(...assistantChunks)
437
+ }
438
+
439
+ return { chunks, directMappings }
440
+ }
441
+
442
+ async function syncSessionToThread({
443
+ client,
444
+ discordClient,
445
+ directory,
446
+ channelId,
447
+ sessionId,
448
+ sessionTitle,
449
+ }: {
450
+ client: OpencodeClient
451
+ discordClient: Client
452
+ directory: string
453
+ channelId: string
454
+ sessionId: string
455
+ sessionTitle?: string | null
456
+ }): Promise<void> {
457
+ const messagesResponse = await client.session.messages({
458
+ sessionID: sessionId,
459
+ directory,
460
+ }).catch((error) => {
461
+ return new Error(`Failed to fetch messages for session ${sessionId}`, {
462
+ cause: error,
463
+ })
464
+ })
465
+ if (messagesResponse instanceof Error) {
466
+ throw messagesResponse
467
+ }
468
+ const messages = messagesResponse.data || []
469
+
470
+ // Pure derivation from opencode events: if the latest user turn has
471
+ // <discord-user /> metadata, kimaki's thread runtime owns this session.
472
+ // Skip external sync entirely. When the user resumes from CLI/TUI the
473
+ // latest user turn will lack the tag, so sync picks it up naturally.
474
+ if (isLatestUserTurnFromDiscord({ messages })) {
475
+ return
476
+ }
477
+
478
+ const thread = await ensureExternalSessionThread({
479
+ discordClient,
480
+ channelId,
481
+ sessionId,
482
+ sessionTitle,
483
+ messages,
484
+ })
485
+ if (thread === null) {
486
+ return
487
+ }
488
+ if (thread instanceof Error) {
489
+ throw thread
490
+ }
491
+
492
+ const [existingPartIds, verbosity] = await Promise.all([
493
+ getPartMessageIds(thread.id),
494
+ getChannelVerbosity(thread.parentId || thread.id),
495
+ ])
496
+ const syncedPartIds = new Set(existingPartIds)
497
+
498
+ const { chunks, directMappings } = collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread })
499
+
500
+ // Persist mappings for user parts that originated from this Discord thread
501
+ if (directMappings.length > 0) {
502
+ await setPartMessagesBatch(directMappings)
503
+ }
504
+
505
+ const batched = batchChunksForDiscord(chunks)
506
+ for (const batch of batched) {
507
+ const sentMessage = await sendThreadMessage(thread, batch.content)
508
+ await setPartMessagesBatch(
509
+ batch.partIds.map((partId) => ({
510
+ partId,
511
+ messageId: sentMessage.id,
512
+ threadId: thread.id,
513
+ })),
514
+ )
515
+ }
516
+ }
517
+
518
+ // Pulse typing indicator for sessions that are currently busy.
519
+ // Takes the global session statuses map (already fetched) and sends
520
+ // typing to threads whose session is busy and still managed by external_poll.
521
+ async function pulseTypingForBusySessions({
522
+ discordClient,
523
+ statuses,
524
+ }: {
525
+ discordClient: Client
526
+ statuses: Record<string, { type: string }>
527
+ }): Promise<void> {
528
+ for (const [sessionId, status] of Object.entries(statuses)) {
529
+ if (status.type !== 'busy') {
530
+ continue
531
+ }
532
+ const threadId = await getThreadIdBySessionId(sessionId)
533
+ if (!threadId) {
534
+ continue
535
+ }
536
+ // Skip sessions already managed by the runtime (source='kimaki')
537
+ const source = await getThreadSessionSource(threadId)
538
+ if (source && source !== 'external_poll') {
539
+ continue
540
+ }
541
+ const thread = await discordClient.channels.fetch(threadId).catch(() => {
542
+ return null
543
+ })
544
+ if (thread?.isThread()) {
545
+ await thread.sendTyping().catch(() => {})
546
+ }
547
+ }
548
+ }
549
+
550
+ // Use experimental.session.list (global, all directories) to reduce from
551
+ // N*2 HTTP calls to 1 global list + per-active-directory status calls.
552
+ async function pollExternalSessions({
553
+ discordClient,
554
+ }: {
555
+ discordClient: Client
556
+ }): Promise<void> {
557
+ const trackedChannels = await listTrackedTextChannels()
558
+ const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
559
+ .filter((t) => {
560
+ return fs.existsSync(t.directory)
561
+ })
562
+ if (directoryTargets.length === 0) {
563
+ return
564
+ }
565
+
566
+ // Build a lookup: directory → { channelId, startMs }
567
+ const directoryMap = new Map<string, { channelId: string; startMs: number }>()
568
+ for (const target of directoryTargets) {
569
+ directoryMap.set(target.directory, {
570
+ channelId: target.channelId,
571
+ startMs: target.startMs,
572
+ })
573
+ }
574
+
575
+ // Use earliest startMs across all directories for the global query
576
+ const globalStartMs = Math.min(...directoryTargets.map((t) => {
577
+ return t.startMs
578
+ }))
579
+
580
+ // Get one opencode client — try each existing directory until one succeeds
581
+ let client: OpencodeClient | undefined
582
+ for (const target of directoryTargets) {
583
+ const result = await initializeOpencodeForDirectory(target.directory, {
584
+ channelId: target.channelId,
585
+ })
586
+ if (!(result instanceof Error)) {
587
+ client = result()
588
+ break
589
+ }
590
+ }
591
+ if (!client) {
592
+ return
593
+ }
594
+
595
+ // One global API call for all sessions across all directories.
596
+ // Results are sorted by most recently updated, so a fixed limit of 50
597
+ // is enough — we always get the most active sessions first.
598
+ const sessionsResponse = await client.experimental.session.list({
599
+ roots: true,
600
+ start: globalStartMs,
601
+ limit: 50,
602
+ }).catch((error) => {
603
+ return new Error('Failed to list global sessions', { cause: error })
604
+ })
605
+ if (sessionsResponse instanceof Error) {
606
+ logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`)
607
+ return
608
+ }
609
+
610
+ const allSessions = sessionsResponse.data || []
611
+
612
+ // Group sessions by directory, filtering to tracked directories only
613
+ const sessionsByDirectory = new Map<string, GlobalListedSession[]>()
614
+ for (const session of allSessions) {
615
+ const target = directoryMap.get(session.directory)
616
+ if (!target) {
617
+ continue
618
+ }
619
+ // Filter by per-directory startMs (time.updated or time.created)
620
+ if ((session.time.updated || session.time.created || 0) < target.startMs) {
621
+ continue
622
+ }
623
+ // Skip sessions whose title hasn't been generated yet
624
+ if (/^new session\s*-/i.test(session.title || '')) {
625
+ continue
626
+ }
627
+ const existing = sessionsByDirectory.get(session.directory) || []
628
+ existing.push(session)
629
+ sessionsByDirectory.set(session.directory, existing)
630
+ }
631
+
632
+ // Fetch session.status() only for directories that have sessions to sync.
633
+ // session.status() is instance-scoped (uses x-opencode-directory header),
634
+ // so we must call it per directory — but only for active ones, not all 30+.
635
+ const activeDirectories = [...sessionsByDirectory.keys()]
636
+ const statusResults = await Promise.all(
637
+ activeDirectories.map(async (directory) => {
638
+ const res = await client.session.status({ directory }).catch(() => {
639
+ return null
640
+ })
641
+ return res?.data ? Object.entries(res.data) : []
642
+ }),
643
+ )
644
+ const mergedStatuses = Object.fromEntries(statusResults.flat()) as Record<string, { type: string }>
645
+
646
+ // Pulse typing for busy sessions
647
+ await pulseTypingForBusySessions({ discordClient, statuses: mergedStatuses }).catch(() => {})
648
+
649
+ for (const [directory, sessions] of sessionsByDirectory) {
650
+ const target = directoryMap.get(directory)!
651
+ const sorted = sortSessionsByRecency(sessions)
652
+ logger.log(`[EXTERNAL_SYNC] ${directory}: ${sorted.length} sessions to sync`)
653
+
654
+ for (const session of sorted) {
655
+ await syncSessionToThread({
656
+ client,
657
+ discordClient,
658
+ directory,
659
+ channelId: target.channelId,
660
+ sessionId: session.id,
661
+ sessionTitle: session.title,
662
+ }).catch((error) => {
663
+ logger.warn(
664
+ `[EXTERNAL_SYNC] Failed syncing session ${session.id}: ${error instanceof Error ? error.message : String(error)}`,
665
+ )
666
+ void notifyError(
667
+ error instanceof Error ? error : new Error(String(error)),
668
+ `External session sync failed for ${session.id}`,
669
+ )
670
+ })
671
+ }
672
+ }
673
+ }
674
+
675
+ export function startExternalOpencodeSessionSync({
676
+ discordClient,
677
+ }: {
678
+ discordClient: Client
679
+ }): void {
680
+ if (
681
+ process.env.KIMAKI_VITEST &&
682
+ process.env.KIMAKI_ENABLE_EXTERNAL_OPENCODE_SYNC !== '1'
683
+ ) {
684
+ return
685
+ }
686
+ if (externalSyncInterval) {
687
+ return
688
+ }
689
+
690
+ logger.log(`[EXTERNAL_SYNC] started, polling every ${EXTERNAL_SYNC_INTERVAL_MS}ms`)
691
+ let polling = false
692
+ const runPoll = async (): Promise<void> => {
693
+ if (polling) {
694
+ return
695
+ }
696
+ polling = true
697
+ const result = await pollExternalSessions({ discordClient }).catch(
698
+ (e) => new Error('External session poll failed', { cause: e }),
699
+ )
700
+ polling = false
701
+ if (result instanceof Error) {
702
+ logger.warn(`[EXTERNAL_SYNC] ${result.message}`)
703
+ void notifyError(result, 'External session poll top-level failure')
704
+ }
705
+ }
706
+
707
+ void runPoll()
708
+ externalSyncInterval = setInterval(() => {
709
+ void runPoll()
710
+ }, EXTERNAL_SYNC_INTERVAL_MS)
711
+ }
712
+
713
+ export function stopExternalOpencodeSessionSync(): void {
714
+ if (!externalSyncInterval) {
715
+ return
716
+ }
717
+ clearInterval(externalSyncInterval)
718
+ externalSyncInterval = null
719
+ }
720
+
721
+ export const externalOpencodeSyncInternals = {
722
+ getRenderableUserTextParts,
723
+ getSessionThreadName,
724
+ groupTrackedChannelsByDirectory,
725
+ sortSessionsByRecency,
726
+ parseDiscordOriginMetadata,
727
+ getDiscordOriginMetadataFromMessage,
728
+ isLatestUserTurnFromDiscord,
729
+ }