@brianli/kimaki 0.4.72-brianli.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.
Files changed (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,536 @@
1
+ // OpenCode plugin for Kimaki Discord bot.
2
+ // Provides tools for Discord integration like listing users for mentions.
3
+ // Also injects synthetic message parts for branch changes and idle-time awareness.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import dedent from 'string-dedent';
8
+ import { z } from 'zod';
9
+ // Inlined from '@opencode-ai/plugin/tool' because the subpath value import
10
+ // fails at runtime in global npm installs (#35). Opencode loads this plugin
11
+ // file in its own process and resolves modules from kimaki's install dir,
12
+ // but the '/tool' subpath export isn't found by opencode's module resolver.
13
+ // The type-only imports above are fine (erased at compile time).
14
+ // The opencode docs recommend `import { tool } from '@opencode-ai/plugin'`
15
+ // (main entry) but their index.d.ts uses `export * from "./tool"` which
16
+ // doesn't re-export the tool function under nodenext resolution because
17
+ // tool is a merged function+namespace declaration.
18
+ function tool(input) {
19
+ return input;
20
+ }
21
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
22
+ import { REST, Routes } from 'discord.js';
23
+ import * as errore from 'errore';
24
+ import { getPrisma, createIpcRequest, getIpcRequestById } from './database.js';
25
+ import { getBotToken } from './bot-token.js';
26
+ import { createDiscordRest } from './discord-api.js';
27
+ import { setDataDir } from './config.js';
28
+ import { archiveThread, reactToThread } from './discord-utils.js';
29
+ import { createLogger, formatErrorWithStack, LogPrefix, setLogFilePath } from './logger.js';
30
+ import { initSentry, notifyError } from './sentry.js';
31
+ import { execAsync } from './worktree-utils.js';
32
+ // Regex to match emoji characters (covers most common emojis)
33
+ // Includes: emoji presentation sequences, skin tone modifiers, ZWJ sequences, regional indicators
34
+ const EMOJI_REGEX = /^(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Regional_Indicator}{2}|\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|[\u{1F3FB}-\u{1F3FF}]|(?:\p{Emoji}(?:\u{200D}\p{Emoji})+))$/u;
35
+ function isEmoji(str) {
36
+ return EMOJI_REGEX.test(str);
37
+ }
38
+ const logger = createLogger(LogPrefix.OPENCODE);
39
+ // condenseMemoryMd lives in condense-memory.ts — must NOT be exported from
40
+ // this file because OpenCode's plugin loader calls every exported function
41
+ // as a plugin initializer, which would crash marked's Lexer with non-string input.
42
+ import { condenseMemoryMd } from './condense-memory.js';
43
+ const FILE_UPLOAD_TIMEOUT_MS = 6 * 60 * 1000;
44
+ const DEFAULT_FILE_UPLOAD_MAX_FILES = 5;
45
+ const ACTION_BUTTON_TIMEOUT_MS = 30 * 1000;
46
+ async function resolveGitState({ directory, }) {
47
+ const branchResult = await errore.tryAsync(() => {
48
+ return execAsync('git symbolic-ref --short HEAD', { cwd: directory });
49
+ });
50
+ if (!(branchResult instanceof Error)) {
51
+ const branch = branchResult.stdout.trim();
52
+ if (branch) {
53
+ return {
54
+ key: `branch:${branch}`,
55
+ kind: 'branch',
56
+ label: branch,
57
+ warning: null,
58
+ };
59
+ }
60
+ }
61
+ const shaResult = await errore.tryAsync(() => {
62
+ return execAsync('git rev-parse --short HEAD', { cwd: directory });
63
+ });
64
+ if (shaResult instanceof Error) {
65
+ return null;
66
+ }
67
+ const shortSha = shaResult.stdout.trim();
68
+ if (!shortSha) {
69
+ return null;
70
+ }
71
+ const superprojectResult = await errore.tryAsync(() => {
72
+ return execAsync('git rev-parse --show-superproject-working-tree', {
73
+ cwd: directory,
74
+ });
75
+ });
76
+ const superproject = superprojectResult instanceof Error ? '' : superprojectResult.stdout.trim();
77
+ if (superproject) {
78
+ return {
79
+ key: `detached-submodule:${shortSha}`,
80
+ kind: 'detached-submodule',
81
+ label: `detached submodule @ ${shortSha}`,
82
+ warning: `[Warning: Submodule is in detached HEAD at ${shortSha}. ` +
83
+ 'Create or switch to a branch before committing.]',
84
+ };
85
+ }
86
+ return {
87
+ key: `detached-head:${shortSha}`,
88
+ kind: 'detached-head',
89
+ label: `detached HEAD @ ${shortSha}`,
90
+ warning: `[Warning: Repository is in detached HEAD at ${shortSha}. ` +
91
+ 'Create or switch to a branch before committing.]',
92
+ };
93
+ }
94
+ const kimakiPlugin = async ({ directory }) => {
95
+ // Initialize Sentry in the plugin process (runs inside OpenCode server, not bot)
96
+ initSentry();
97
+ const botToken = getBotToken({ allowDatabase: false })?.token;
98
+ const dataDir = process.env.KIMAKI_DATA_DIR;
99
+ if (dataDir) {
100
+ setDataDir(dataDir);
101
+ // Append to the same log file the bot process created (no truncation)
102
+ setLogFilePath(dataDir);
103
+ }
104
+ if (!botToken) {
105
+ // No token available, skip Discord tools
106
+ return {};
107
+ }
108
+ const rest = createDiscordRest(botToken);
109
+ const port = process.env.OPENCODE_PORT;
110
+ const client = port
111
+ ? createOpencodeClient({
112
+ baseUrl: `http://127.0.0.1:${port}`,
113
+ })
114
+ : null;
115
+ // Per-session state for synthetic part injection
116
+ const sessionGitStates = new Map();
117
+ const sessionLastMessageTime = new Map();
118
+ // Track whether we've already injected MEMORY.md contents for each session
119
+ const sessionMemoryInjected = new Set();
120
+ return {
121
+ tool: {
122
+ kimaki_list_discord_users: tool({
123
+ description: 'Search for Discord users in a guild/server. Returns user IDs needed for mentions (<@userId>). Use the guildId from the system message.',
124
+ args: {
125
+ guildId: z.string().describe('Discord guild/server ID'),
126
+ query: z
127
+ .string()
128
+ .optional()
129
+ .describe('Search query to filter users by name (optional, returns first 20 if not provided)'),
130
+ },
131
+ async execute({ guildId, query }) {
132
+ const members = await (async () => {
133
+ if (query) {
134
+ return (await rest.get(Routes.guildMembersSearch(guildId), {
135
+ query: new URLSearchParams({ query, limit: '20' }),
136
+ }));
137
+ }
138
+ // No query, list first 20 members
139
+ return (await rest.get(Routes.guildMembers(guildId), {
140
+ query: new URLSearchParams({ limit: '20' }),
141
+ }));
142
+ })();
143
+ if (members.length === 0) {
144
+ return query
145
+ ? `No users found matching "${query}"`
146
+ : 'No users found in guild';
147
+ }
148
+ const userList = members
149
+ .map((m) => {
150
+ const displayName = m.nick || m.user.global_name || m.user.username;
151
+ return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
152
+ })
153
+ .join('\n');
154
+ const header = query
155
+ ? `Found ${members.length} users matching "${query}":`
156
+ : `Found ${members.length} users:`;
157
+ return `${header}\n${userList}`;
158
+ },
159
+ }),
160
+ kimaki_mark_thread: tool({
161
+ description: 'Mark the current Discord thread with emoji reactions and update the session title. Only pass emoji characters (e.g., "🚀", "🐛", "📦"). Do NOT use ✅ as it is reserved for "session completed" indicator. This lets users create custom tagging systems visible in both Discord and OpenCode.',
162
+ args: {
163
+ emojis: z
164
+ .array(z.string())
165
+ .describe('Array of emoji characters to add as reactions and prepend to session title. Only emojis allowed, no text.'),
166
+ },
167
+ async execute({ emojis }, context) {
168
+ if (!emojis || emojis.length === 0) {
169
+ return 'No emojis provided';
170
+ }
171
+ // Validate all inputs are emojis
172
+ const invalidEmojis = emojis.filter((e) => {
173
+ return !isEmoji(e);
174
+ });
175
+ if (invalidEmojis.length > 0) {
176
+ throw new Error(`Invalid emoji characters: ${invalidEmojis.join(', ')}. Only emoji characters are allowed.`);
177
+ }
178
+ const prisma = await getPrisma();
179
+ const row = await prisma.thread_sessions.findFirst({
180
+ where: { session_id: context.sessionID },
181
+ select: { thread_id: true },
182
+ });
183
+ if (!row?.thread_id) {
184
+ return 'Could not find thread for current session';
185
+ }
186
+ // Add reactions to thread starter message (reactToThread handles errors internally)
187
+ const addedEmojis = [];
188
+ for (const emoji of emojis) {
189
+ await reactToThread({ rest, threadId: row.thread_id, emoji });
190
+ addedEmojis.push(emoji);
191
+ }
192
+ // Update session title with emoji prefix
193
+ if (client && addedEmojis.length > 0) {
194
+ const updateResult = await errore.tryAsync({
195
+ try: async () => {
196
+ const sessionResponse = await client.session.get({
197
+ sessionID: context.sessionID,
198
+ });
199
+ if (sessionResponse.data) {
200
+ const currentTitle = sessionResponse.data.title || '';
201
+ const emojiPrefix = addedEmojis.join('');
202
+ // Avoid duplicating emojis if they're already at the start
203
+ const newTitle = currentTitle.startsWith(emojiPrefix)
204
+ ? currentTitle
205
+ : `${emojiPrefix} ${currentTitle}`.trim();
206
+ await client.session.update({
207
+ sessionID: context.sessionID,
208
+ title: newTitle,
209
+ });
210
+ }
211
+ },
212
+ catch: (error) => {
213
+ return new Error('Failed to update session title', {
214
+ cause: error,
215
+ });
216
+ },
217
+ });
218
+ if (updateResult instanceof Error) {
219
+ logger.warn(`[kimaki_mark_thread] ${formatErrorWithStack(updateResult)}`);
220
+ }
221
+ }
222
+ return addedEmojis.length > 0
223
+ ? `Marked thread with: ${addedEmojis.join(' ')}`
224
+ : 'Failed to add any emoji reactions';
225
+ },
226
+ }),
227
+ kimaki_file_upload: tool({
228
+ description: 'Prompt the Discord user to upload files using a native file picker modal. ' +
229
+ 'The user sees a button, clicks it, and gets a file upload dialog. ' +
230
+ 'Returns the local file paths of downloaded files in the project directory. ' +
231
+ 'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
232
+ 'IMPORTANT: Always call this tool last in your message, after all text parts.',
233
+ args: {
234
+ prompt: z
235
+ .string()
236
+ .describe('Message shown to the user explaining what files to upload'),
237
+ maxFiles: z
238
+ .number()
239
+ .min(1)
240
+ .max(10)
241
+ .optional()
242
+ .describe('Maximum number of files the user can upload (1-10, default 5)'),
243
+ },
244
+ async execute({ prompt, maxFiles }, context) {
245
+ const prisma = await getPrisma();
246
+ const row = await prisma.thread_sessions.findFirst({
247
+ where: { session_id: context.sessionID },
248
+ select: { thread_id: true },
249
+ });
250
+ if (!row?.thread_id) {
251
+ return 'Could not find thread for current session';
252
+ }
253
+ // Insert IPC request for the bot to pick up via polling
254
+ const ipcRow = await createIpcRequest({
255
+ type: 'file_upload',
256
+ sessionId: context.sessionID,
257
+ threadId: row.thread_id,
258
+ payload: JSON.stringify({
259
+ prompt,
260
+ maxFiles: maxFiles || DEFAULT_FILE_UPLOAD_MAX_FILES,
261
+ directory: context.directory,
262
+ }),
263
+ });
264
+ // Poll for response from the bot process
265
+ const deadline = Date.now() + FILE_UPLOAD_TIMEOUT_MS;
266
+ const POLL_INTERVAL_MS = 300;
267
+ while (Date.now() < deadline) {
268
+ await new Promise((resolve) => {
269
+ setTimeout(resolve, POLL_INTERVAL_MS);
270
+ });
271
+ const updated = await getIpcRequestById({ id: ipcRow.id });
272
+ if (!updated || updated.status === 'cancelled') {
273
+ return 'File upload was cancelled';
274
+ }
275
+ if (updated.response) {
276
+ const parsed = JSON.parse(updated.response);
277
+ if (parsed.error) {
278
+ return `File upload failed: ${parsed.error}`;
279
+ }
280
+ const filePaths = parsed.filePaths || [];
281
+ if (filePaths.length === 0) {
282
+ return 'No files were uploaded (user may have cancelled or sent a new message)';
283
+ }
284
+ return `Files uploaded successfully:\n${filePaths.join('\n')}`;
285
+ }
286
+ }
287
+ return 'File upload timed out - user did not upload files within the time limit';
288
+ },
289
+ }),
290
+ kimaki_action_buttons: tool({
291
+ description: dedent `
292
+ Show action buttons in the current Discord thread for quick confirmations.
293
+ Use this when the user can respond by clicking one of up to 3 buttons.
294
+ Prefer a single button whenever possible.
295
+ Default color is white (same visual style as permission deny button).
296
+ If you need more than 3 options, use the question tool instead.
297
+ IMPORTANT: Always call this tool last in your message, after all text parts.
298
+
299
+ Examples:
300
+ - buttons: [{"label":"Yes, proceed"}]
301
+ - buttons: [{"label":"Approve","color":"green"}]
302
+ - buttons: [
303
+ {"label":"Confirm","color":"blue"},
304
+ {"label":"Cancel","color":"white"}
305
+ ]
306
+ `,
307
+ args: {
308
+ buttons: z
309
+ .array(z.object({
310
+ label: z
311
+ .string()
312
+ .min(1)
313
+ .max(80)
314
+ .describe('Button label shown to the user (1-80 chars)'),
315
+ color: z
316
+ .enum(['white', 'blue', 'green', 'red'])
317
+ .optional()
318
+ .describe('Optional button color. white is default and preferred for most confirmations.'),
319
+ }))
320
+ .min(1)
321
+ .max(3)
322
+ .describe('Array of 1-3 action buttons. Prefer one button whenever possible.'),
323
+ },
324
+ async execute({ buttons }, context) {
325
+ const prisma = await getPrisma();
326
+ const row = await prisma.thread_sessions.findFirst({
327
+ where: { session_id: context.sessionID },
328
+ select: { thread_id: true },
329
+ });
330
+ if (!row?.thread_id) {
331
+ return 'Could not find thread for current session';
332
+ }
333
+ // Insert IPC request for the bot to pick up via polling
334
+ const ipcRow = await createIpcRequest({
335
+ type: 'action_buttons',
336
+ sessionId: context.sessionID,
337
+ threadId: row.thread_id,
338
+ payload: JSON.stringify({
339
+ buttons,
340
+ directory: context.directory,
341
+ }),
342
+ });
343
+ // Wait for bot to acknowledge (status changes from pending to processing/completed)
344
+ const deadline = Date.now() + ACTION_BUTTON_TIMEOUT_MS;
345
+ const POLL_INTERVAL_MS = 200;
346
+ while (Date.now() < deadline) {
347
+ await new Promise((resolve) => {
348
+ setTimeout(resolve, POLL_INTERVAL_MS);
349
+ });
350
+ const updated = await getIpcRequestById({ id: ipcRow.id });
351
+ if (!updated || updated.status === 'cancelled') {
352
+ return 'Action button request was cancelled';
353
+ }
354
+ if (updated.response) {
355
+ const parsed = JSON.parse(updated.response);
356
+ if (parsed.error) {
357
+ return `Action button request failed: ${parsed.error}`;
358
+ }
359
+ return `Action button(s) shown: ${buttons.map((button) => button.label).join(', ')}`;
360
+ }
361
+ }
362
+ return 'Action button request timed out';
363
+ },
364
+ }),
365
+ kimaki_archive_thread: tool({
366
+ description: 'Archive the current Discord thread to hide it from the Discord left sidebar. Only call this when the user explicitly asks to close or archive the thread and only after your final message contains no new information the user needs to read (for example, after confirming a git push). If the user asks to set a reminder or scheduled task and it is successfully created, archive the thread after sending the final confirmation. If you archive too early, the user may miss that message notification in Discord. This tool also aborts the current session, so it should ALWAYS be called as the last tool in your response.',
367
+ args: {},
368
+ async execute(_args, context) {
369
+ const prisma = await getPrisma();
370
+ const row = await prisma.thread_sessions.findFirst({
371
+ where: { session_id: context.sessionID },
372
+ select: { thread_id: true },
373
+ });
374
+ if (!row?.thread_id) {
375
+ return 'Could not find thread for current session';
376
+ }
377
+ await archiveThread({
378
+ rest,
379
+ threadId: row.thread_id,
380
+ sessionId: context.sessionID,
381
+ client,
382
+ archiveDelay: 10_000,
383
+ });
384
+ return 'Thread archived and session stopped';
385
+ },
386
+ }),
387
+ },
388
+ // Inject synthetic parts for branch changes and idle-time gaps.
389
+ // Synthetic parts are hidden from the TUI but sent to the model,
390
+ // keeping it aware of context changes without cluttering the UI.
391
+ 'chat.message': async (input, output) => {
392
+ const hookResult = await errore.tryAsync({
393
+ try: async () => {
394
+ const now = Date.now();
395
+ const first = output.parts[0];
396
+ if (!first) {
397
+ return;
398
+ }
399
+ const { sessionID } = input;
400
+ const messageID = typeof first === 'object' && first !== null && 'messageID' in first
401
+ ? first.messageID
402
+ : '';
403
+ // -- Branch / detached HEAD detection --
404
+ // Injects context when git state first appears or changes mid-session.
405
+ const gitState = await resolveGitState({ directory });
406
+ if (gitState) {
407
+ const previousState = sessionGitStates.get(sessionID);
408
+ if (!previousState || previousState.key !== gitState.key) {
409
+ const info = (() => {
410
+ if (gitState.warning) {
411
+ return gitState.warning;
412
+ }
413
+ if (previousState?.kind === 'branch') {
414
+ return `[Branch changed: ${previousState.label} -> ${gitState.label}]`;
415
+ }
416
+ return `[Current branch: ${gitState.label}]`;
417
+ })();
418
+ sessionGitStates.set(sessionID, gitState);
419
+ output.parts.push({
420
+ id: crypto.randomUUID(),
421
+ sessionID,
422
+ messageID,
423
+ type: 'text',
424
+ text: info,
425
+ synthetic: true,
426
+ });
427
+ }
428
+ }
429
+ // -- MEMORY.md injection --
430
+ // On the first user message in a session, read MEMORY.md from the
431
+ // project root and inject a condensed table of contents (headings
432
+ // with line numbers, bodies collapsed to ...). The agent can use
433
+ // Read with offset/limit to drill into specific sections.
434
+ if (!sessionMemoryInjected.has(sessionID)) {
435
+ sessionMemoryInjected.add(sessionID);
436
+ const memoryPath = path.join(directory, 'MEMORY.md');
437
+ const memoryContent = await fs.promises
438
+ .readFile(memoryPath, 'utf-8')
439
+ .catch(() => null);
440
+ if (memoryContent) {
441
+ const condensed = condenseMemoryMd(memoryContent);
442
+ output.parts.push({
443
+ id: crypto.randomUUID(),
444
+ sessionID,
445
+ messageID,
446
+ type: 'text',
447
+ text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
448
+ synthetic: true,
449
+ });
450
+ }
451
+ }
452
+ // -- Time since last message --
453
+ // If more than 10 minutes passed since the last user message in this session,
454
+ // inject current time context so the model is aware of the gap.
455
+ const lastTime = sessionLastMessageTime.get(sessionID);
456
+ sessionLastMessageTime.set(sessionID, now);
457
+ if (lastTime) {
458
+ const elapsed = now - lastTime;
459
+ const TEN_MINUTES = 10 * 60 * 1000;
460
+ if (elapsed >= TEN_MINUTES) {
461
+ const totalMinutes = Math.floor(elapsed / 60_000);
462
+ const hours = Math.floor(totalMinutes / 60);
463
+ const minutes = totalMinutes % 60;
464
+ const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`;
465
+ const utcStr = new Date(now)
466
+ .toISOString()
467
+ .replace('T', ' ')
468
+ .replace(/\.\d+Z$/, ' UTC');
469
+ const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
470
+ const localStr = new Date(now).toLocaleString('en-US', {
471
+ timeZone: localTz,
472
+ year: 'numeric',
473
+ month: '2-digit',
474
+ day: '2-digit',
475
+ hour: '2-digit',
476
+ minute: '2-digit',
477
+ hour12: false,
478
+ });
479
+ output.parts.push({
480
+ id: crypto.randomUUID(),
481
+ sessionID,
482
+ messageID,
483
+ type: 'text',
484
+ text: `[${elapsedStr} since last message | UTC: ${utcStr} | Local (${localTz}): ${localStr}]`,
485
+ synthetic: true,
486
+ });
487
+ // -- Memory save reminder on idle gap --
488
+ // When the user comes back after a long break, remind the model
489
+ // to save any important context from the previous conversation.
490
+ output.parts.push({
491
+ id: crypto.randomUUID(),
492
+ sessionID,
493
+ messageID,
494
+ type: 'text',
495
+ text: '<system-reminder>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
496
+ synthetic: true,
497
+ });
498
+ }
499
+ }
500
+ },
501
+ catch: (error) => {
502
+ return new Error('chat.message hook failed', { cause: error });
503
+ },
504
+ });
505
+ if (hookResult instanceof Error) {
506
+ logger.warn(`[opencode-plugin chat.message] ${formatErrorWithStack(hookResult)}`);
507
+ void notifyError(hookResult, 'opencode-plugin chat.message hook failed');
508
+ }
509
+ },
510
+ // Clean up per-session tracking state when sessions are deleted
511
+ event: async ({ event }) => {
512
+ const cleanupResult = await errore.tryAsync({
513
+ try: async () => {
514
+ if (event.type !== 'session.deleted') {
515
+ return;
516
+ }
517
+ const id = event.properties?.info?.id;
518
+ if (!id) {
519
+ return;
520
+ }
521
+ sessionGitStates.delete(id);
522
+ sessionLastMessageTime.delete(id);
523
+ sessionMemoryInjected.delete(id);
524
+ },
525
+ catch: (error) => {
526
+ return new Error('event hook failed', { cause: error });
527
+ },
528
+ });
529
+ if (cleanupResult instanceof Error) {
530
+ logger.warn(`[opencode-plugin event] ${formatErrorWithStack(cleanupResult)}`);
531
+ void notifyError(cleanupResult, 'opencode-plugin event hook failed');
532
+ }
533
+ },
534
+ };
535
+ };
536
+ export { kimakiPlugin };
@@ -0,0 +1,98 @@
1
+ import { test, expect, describe } from 'vitest';
2
+ import { condenseMemoryMd } from './condense-memory.js';
3
+ describe('condenseMemoryMd', () => {
4
+ test('multiple headings with body content', () => {
5
+ const content = [
6
+ '# Project Overview',
7
+ '',
8
+ 'This is a big project with many things.',
9
+ 'It does X, Y, and Z.',
10
+ '',
11
+ '## Auth Architecture',
12
+ '',
13
+ 'JWT tokens with 15min expiry.',
14
+ 'Refresh tokens in httpOnly cookies.',
15
+ 'Session stored in Redis.',
16
+ '',
17
+ '## User Preferences',
18
+ '',
19
+ '- kebab-case filenames',
20
+ '- errore-style errors',
21
+ '- no emojis',
22
+ '',
23
+ '### API Conventions',
24
+ '',
25
+ 'All routes return { data, error }.',
26
+ 'Use spiceflow for the server.',
27
+ '',
28
+ ].join('\n');
29
+ expect(condenseMemoryMd(content)).toMatchInlineSnapshot(`
30
+ "1: # Project Overview
31
+ ...
32
+ 6: ## Auth Architecture
33
+ ...
34
+ 12: ## User Preferences
35
+ ...
36
+ 18: ### API Conventions
37
+ ..."
38
+ `);
39
+ });
40
+ test('body text before first heading', () => {
41
+ const content = [
42
+ 'Some preamble notes.',
43
+ '',
44
+ '# First Heading',
45
+ '',
46
+ 'Content here.',
47
+ '',
48
+ ].join('\n');
49
+ expect(condenseMemoryMd(content)).toMatchInlineSnapshot(`
50
+ "...
51
+ 3: # First Heading
52
+ ..."
53
+ `);
54
+ });
55
+ test('no headings at all', () => {
56
+ const content = 'Just some notes.\nMore notes.\n';
57
+ expect(condenseMemoryMd(content)).toMatchInlineSnapshot(`"..."`);
58
+ });
59
+ test('empty content', () => {
60
+ expect(condenseMemoryMd('')).toMatchInlineSnapshot(`""`);
61
+ });
62
+ test('consecutive headings without body', () => {
63
+ const content = [
64
+ '# H1',
65
+ '## H2',
66
+ '### H3',
67
+ '',
68
+ 'Some body.',
69
+ '',
70
+ ].join('\n');
71
+ expect(condenseMemoryMd(content)).toMatchInlineSnapshot(`
72
+ "1: # H1
73
+ 2: ## H2
74
+ 3: ### H3
75
+ ..."
76
+ `);
77
+ });
78
+ test('heading with code block body', () => {
79
+ const content = [
80
+ '# Config',
81
+ '',
82
+ '```json',
83
+ '{ "key": "value" }',
84
+ '```',
85
+ '',
86
+ '## Notes',
87
+ '',
88
+ 'Some text.',
89
+ '',
90
+ ].join('\n');
91
+ expect(condenseMemoryMd(content)).toMatchInlineSnapshot(`
92
+ "1: # Config
93
+ ...
94
+ 7: ## Notes
95
+ ..."
96
+ `);
97
+ });
98
+ });