@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,417 @@
1
+ // Filesystem -> Discord sync.
2
+ // Reads markdown files and creates/updates/deletes forum threads to match.
3
+ // Handles upsert logic: new files create threads, existing files update them.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { MessageFlags } from 'discord.js';
7
+ import { createLogger } from '../logger.js';
8
+ import { appendProjectChannelFooter, extractStarterContent, getStringValue, parseFrontmatter, toStringArray, } from './markdown.js';
9
+ import { resolveForumChannel } from './discord-operations.js';
10
+ import { syncSingleThreadToFile } from './sync-to-files.js';
11
+ import { ForumSyncOperationError, shouldIgnorePath, } from './types.js';
12
+ const forumLogger = createLogger('FORUM');
13
+ // Fields managed by forum sync that should not be set by external writers (e.g. AI model).
14
+ // If a file has never been synced (no lastSyncedAt), these fields are stripped to prevent
15
+ // model-invented values from causing sync errors (e.g. fake threadId -> fetch fails,
16
+ // future lastSyncedAt -> file permanently skipped).
17
+ const SYSTEM_MANAGED_FIELDS = [
18
+ 'threadId',
19
+ 'forumChannelId',
20
+ 'lastSyncedAt',
21
+ 'lastMessageId',
22
+ 'messageCount',
23
+ 'author',
24
+ 'authorId',
25
+ 'createdAt',
26
+ 'lastUpdated',
27
+ 'project',
28
+ 'projectChannelId',
29
+ ];
30
+ /** Check that a value is a valid ISO date string that isn't in the future. */
31
+ function isValidPastIsoDate({ value }) {
32
+ if (typeof value !== 'string')
33
+ return false;
34
+ const parsed = Date.parse(value);
35
+ if (!Number.isFinite(parsed))
36
+ return false;
37
+ return parsed <= Date.now();
38
+ }
39
+ function stripSystemFieldsFromUnsyncedFile({ frontmatter, }) {
40
+ if (isValidPastIsoDate({ value: frontmatter.lastSyncedAt }))
41
+ return frontmatter;
42
+ const cleaned = { ...frontmatter };
43
+ for (const field of SYSTEM_MANAGED_FIELDS) {
44
+ delete cleaned[field];
45
+ }
46
+ return cleaned;
47
+ }
48
+ function isValidDiscordSnowflake({ value }) {
49
+ return /^\d{17,20}$/.test(value);
50
+ }
51
+ async function collectMarkdownEntries({ dir, outputDir, }) {
52
+ const exists = await fs.promises
53
+ .access(dir)
54
+ .then(() => true)
55
+ .catch(() => false);
56
+ if (!exists)
57
+ return [];
58
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
59
+ const relativeSub = path.relative(outputDir, dir);
60
+ const subfolder = relativeSub && relativeSub !== '.' ? relativeSub : undefined;
61
+ const markdownFiles = entries
62
+ .filter((entry) => {
63
+ return entry.isFile() && entry.name.endsWith('.md');
64
+ })
65
+ .map((entry) => {
66
+ return { filePath: path.join(dir, entry.name), subfolder };
67
+ });
68
+ const nestedEntries = await Promise.all(entries
69
+ .filter((entry) => {
70
+ return entry.isDirectory();
71
+ })
72
+ .map(async (entry) => {
73
+ return await collectMarkdownEntries({
74
+ dir: path.join(dir, entry.name),
75
+ outputDir,
76
+ });
77
+ }));
78
+ return [...markdownFiles, ...nestedEntries.flat()];
79
+ }
80
+ function resolveTagIds({ forumChannel, tagNames, }) {
81
+ if (tagNames.length === 0)
82
+ return [];
83
+ const normalizedWanted = new Set(tagNames.map((tag) => tag.toLowerCase().trim()));
84
+ return forumChannel.availableTags
85
+ .filter((tag) => normalizedWanted.has(tag.name.toLowerCase().trim()))
86
+ .map((tag) => tag.id);
87
+ }
88
+ /** Ensure all requested tag names exist on the forum channel, creating any missing ones. */
89
+ async function ensureForumTags({ forumChannel, tagNames, }) {
90
+ if (tagNames.length === 0)
91
+ return;
92
+ const existingNames = new Set(forumChannel.availableTags.map((tag) => tag.name.toLowerCase().trim()));
93
+ const missing = tagNames.filter((name) => !existingNames.has(name.toLowerCase().trim()));
94
+ if (missing.length === 0)
95
+ return;
96
+ // Discord forums allow up to 20 tags
97
+ const available = forumChannel.availableTags;
98
+ if (available.length + missing.length > 20)
99
+ return;
100
+ await forumChannel
101
+ .setAvailableTags([...available, ...missing.map((name) => ({ name }))], `Auto-create tags: ${missing.join(', ')}`)
102
+ .catch((cause) => {
103
+ forumLogger.warn(`Failed to create forum tags [${missing.join(', ')}]: ${cause instanceof Error ? cause.message : cause}`);
104
+ });
105
+ }
106
+ function hasTagName({ tags, tagName }) {
107
+ return tags.some((tag) => tag.toLowerCase().trim() === tagName.toLowerCase().trim());
108
+ }
109
+ async function upsertThreadFromFile({ discordClient, forumChannel, filePath, runtimeState, subfolder, project, projectChannelId, }) {
110
+ if (!fs.existsSync(filePath))
111
+ return 'skipped';
112
+ const content = await fs.promises
113
+ .readFile(filePath, 'utf8')
114
+ .catch((cause) => {
115
+ return new ForumSyncOperationError({
116
+ forumChannelId: forumChannel.id,
117
+ reason: `failed to read ${filePath}`,
118
+ cause,
119
+ });
120
+ });
121
+ if (content instanceof Error)
122
+ return content;
123
+ const parsed = parseFrontmatter({ markdown: content });
124
+ const frontmatter = stripSystemFieldsFromUnsyncedFile({
125
+ frontmatter: parsed.frontmatter,
126
+ });
127
+ const rawThreadId = getStringValue({ value: frontmatter.threadId });
128
+ const threadId = rawThreadId && isValidDiscordSnowflake({ value: rawThreadId })
129
+ ? rawThreadId
130
+ : '';
131
+ const title = getStringValue({ value: frontmatter.title }) ||
132
+ path.basename(filePath, '.md');
133
+ const tags = toStringArray({ value: frontmatter.tags });
134
+ const normalizedSubfolder = subfolder?.replaceAll('\\', '/').toLowerCase();
135
+ const isGlobalSubfolder = Boolean(normalizedSubfolder &&
136
+ (normalizedSubfolder === 'global' ||
137
+ normalizedSubfolder.startsWith('global/')));
138
+ const tagsWithScope = isGlobalSubfolder && !hasTagName({ tags, tagName: 'global' })
139
+ ? [...tags, 'global']
140
+ : tags;
141
+ // Add project name as a forum tag if derived from subfolder
142
+ const allTags = project && !hasTagName({ tags: tagsWithScope, tagName: project })
143
+ ? [...tagsWithScope, project]
144
+ : tagsWithScope;
145
+ const starterContent = extractStarterContent({ body: parsed.body });
146
+ // Resolve fallback BEFORE appending footer so an empty body doesn't
147
+ // produce a message that is just the channel footer.
148
+ const baseContent = starterContent || title || 'Untitled post';
149
+ const safeStarterContent = appendProjectChannelFooter({
150
+ content: baseContent,
151
+ projectChannelId,
152
+ });
153
+ const stat = await fs.promises.stat(filePath).catch((cause) => {
154
+ return new ForumSyncOperationError({
155
+ forumChannelId: forumChannel.id,
156
+ reason: `failed to stat ${filePath}`,
157
+ cause,
158
+ });
159
+ });
160
+ if (stat instanceof Error)
161
+ return stat;
162
+ // Skip if file hasn't been modified since last sync
163
+ const lastSyncedAt = Date.parse(getStringValue({ value: frontmatter.lastSyncedAt }));
164
+ if (Number.isFinite(lastSyncedAt) && stat.mtimeMs <= lastSyncedAt)
165
+ return 'skipped';
166
+ await ensureForumTags({ forumChannel, tagNames: allTags });
167
+ const tagIds = resolveTagIds({ forumChannel, tagNames: allTags });
168
+ // No threadId in frontmatter -> create a new thread
169
+ if (!threadId) {
170
+ return await createNewThread({
171
+ forumChannel,
172
+ filePath,
173
+ title,
174
+ safeStarterContent,
175
+ tagIds,
176
+ runtimeState,
177
+ subfolder,
178
+ project,
179
+ projectChannelId,
180
+ });
181
+ }
182
+ // Thread exists -> update it
183
+ return await updateExistingThread({
184
+ discordClient,
185
+ forumChannel,
186
+ filePath,
187
+ threadId,
188
+ title,
189
+ safeStarterContent,
190
+ tagIds,
191
+ runtimeState,
192
+ subfolder,
193
+ project,
194
+ projectChannelId,
195
+ });
196
+ }
197
+ async function createNewThread({ forumChannel, filePath, title, safeStarterContent, tagIds, runtimeState, subfolder, project, projectChannelId, }) {
198
+ const created = await forumChannel.threads
199
+ .create({
200
+ name: title.slice(0, 100) || 'Untitled post',
201
+ message: {
202
+ content: safeStarterContent.slice(0, 2_000),
203
+ flags: MessageFlags.SuppressEmbeds,
204
+ },
205
+ appliedTags: tagIds,
206
+ })
207
+ .catch((cause) => new ForumSyncOperationError({
208
+ forumChannelId: forumChannel.id,
209
+ reason: `failed creating thread from ${filePath}`,
210
+ cause,
211
+ }));
212
+ if (created instanceof Error)
213
+ return created;
214
+ // Re-sync the file to get the new threadId in frontmatter.
215
+ // outputDir is path.dirname(filePath) which already includes the subfolder,
216
+ // so we don't pass subfolder again to avoid double-nesting.
217
+ const syncResult = await syncSingleThreadToFile({
218
+ thread: created,
219
+ forumChannel,
220
+ outputDir: path.dirname(filePath),
221
+ runtimeState,
222
+ previousFilePath: filePath,
223
+ project,
224
+ projectChannelId,
225
+ });
226
+ if (syncResult instanceof Error)
227
+ return syncResult;
228
+ return 'created';
229
+ }
230
+ async function updateExistingThread({ discordClient, forumChannel, filePath, threadId, title, safeStarterContent, tagIds, runtimeState, subfolder, project, projectChannelId, }) {
231
+ const fetchedChannel = await discordClient.channels.fetch(threadId).catch((cause) => new ForumSyncOperationError({
232
+ forumChannelId: forumChannel.id,
233
+ reason: `failed fetching thread ${threadId}`,
234
+ cause,
235
+ }));
236
+ if (fetchedChannel instanceof Error)
237
+ return fetchedChannel;
238
+ if (!fetchedChannel ||
239
+ !fetchedChannel.isThread() ||
240
+ fetchedChannel.parentId !== forumChannel.id) {
241
+ return new ForumSyncOperationError({
242
+ forumChannelId: forumChannel.id,
243
+ reason: `thread ${threadId} not found in forum`,
244
+ });
245
+ }
246
+ const updateResult = await fetchedChannel
247
+ .edit({
248
+ name: title.slice(0, 100) || fetchedChannel.name,
249
+ appliedTags: tagIds,
250
+ })
251
+ .catch((cause) => new ForumSyncOperationError({
252
+ forumChannelId: forumChannel.id,
253
+ reason: `failed editing thread ${threadId}`,
254
+ cause,
255
+ }));
256
+ if (updateResult instanceof Error)
257
+ return updateResult;
258
+ const starterMessage = await fetchedChannel
259
+ .fetchStarterMessage()
260
+ .catch((cause) => {
261
+ return new ForumSyncOperationError({
262
+ forumChannelId: forumChannel.id,
263
+ reason: `failed fetching starter message for ${threadId}`,
264
+ cause,
265
+ });
266
+ });
267
+ if (starterMessage instanceof Error)
268
+ return starterMessage;
269
+ if (starterMessage && starterMessage.content !== safeStarterContent) {
270
+ const editResult = await starterMessage
271
+ .edit({
272
+ content: safeStarterContent.slice(0, 2_000),
273
+ flags: MessageFlags.SuppressEmbeds,
274
+ })
275
+ .catch((cause) => new ForumSyncOperationError({
276
+ forumChannelId: forumChannel.id,
277
+ reason: `failed editing starter message for ${threadId}`,
278
+ cause,
279
+ }));
280
+ if (editResult instanceof Error)
281
+ return editResult;
282
+ }
283
+ // Re-sync the file to update frontmatter with latest state.
284
+ // outputDir is path.dirname(filePath) which already includes the subfolder.
285
+ const syncResult = await syncSingleThreadToFile({
286
+ thread: fetchedChannel,
287
+ forumChannel,
288
+ outputDir: path.dirname(filePath),
289
+ runtimeState,
290
+ project,
291
+ projectChannelId,
292
+ });
293
+ if (syncResult instanceof Error)
294
+ return syncResult;
295
+ return 'updated';
296
+ }
297
+ async function deleteThreadFromFilePath({ discordClient, forumChannel, filePath, }) {
298
+ const filename = path.basename(filePath, '.md');
299
+ if (!/^\d+$/.test(filename))
300
+ return;
301
+ const threadId = filename;
302
+ const fetchedChannel = await discordClient.channels.fetch(threadId).catch((cause) => new ForumSyncOperationError({
303
+ forumChannelId: forumChannel.id,
304
+ reason: `failed fetching deleted thread ${threadId}`,
305
+ cause,
306
+ }));
307
+ if (fetchedChannel instanceof Error)
308
+ return fetchedChannel;
309
+ if (!fetchedChannel ||
310
+ !fetchedChannel.isThread() ||
311
+ fetchedChannel.parentId !== forumChannel.id) {
312
+ return;
313
+ }
314
+ const deleteResult = await fetchedChannel
315
+ .delete('Deleted from forum sync markdown directory')
316
+ .catch((cause) => new ForumSyncOperationError({
317
+ forumChannelId: forumChannel.id,
318
+ reason: `failed deleting thread ${threadId}`,
319
+ cause,
320
+ }));
321
+ if (deleteResult instanceof Error)
322
+ return deleteResult;
323
+ }
324
+ export async function syncFilesToForum({ discordClient, forumChannelId, outputDir, runtimeState, changedFilePaths, deletedFilePaths, }) {
325
+ const forumChannel = await resolveForumChannel({
326
+ discordClient,
327
+ forumChannelId,
328
+ });
329
+ if (forumChannel instanceof Error)
330
+ return forumChannel;
331
+ // When changedFilePaths is provided (from file watcher), derive subfolder from path.
332
+ // Otherwise, recursively scan all markdown files in outputDir.
333
+ const changedEntries = changedFilePaths
334
+ ? changedFilePaths.map((filePath) => {
335
+ const rel = path.relative(outputDir, path.dirname(filePath));
336
+ const subfolder = rel && rel !== '.' ? rel : undefined;
337
+ return { filePath, subfolder };
338
+ })
339
+ : await collectMarkdownEntries({ dir: outputDir, outputDir });
340
+ // Resolve channel names for subfolders (each subfolder name is a Discord channel ID).
341
+ // Cache resolutions to avoid redundant API calls.
342
+ const channelNameCache = new Map();
343
+ const resolveChannelName = async (channelId) => {
344
+ if (channelNameCache.has(channelId))
345
+ return channelNameCache.get(channelId);
346
+ const channel = await discordClient.channels
347
+ .fetch(channelId)
348
+ .catch(() => null);
349
+ const name = channel && 'name' in channel && typeof channel.name === 'string'
350
+ ? channel.name
351
+ : null;
352
+ channelNameCache.set(channelId, name);
353
+ return name;
354
+ };
355
+ const result = {
356
+ created: 0,
357
+ updated: 0,
358
+ skipped: 0,
359
+ deleted: 0,
360
+ };
361
+ for (const { filePath, subfolder } of changedEntries) {
362
+ if (!filePath.endsWith('.md'))
363
+ continue;
364
+ if (runtimeState && shouldIgnorePath({ runtimeState, filePath })) {
365
+ result.skipped += 1;
366
+ continue;
367
+ }
368
+ // Derive project info from subfolder (subfolder name is the channel ID).
369
+ // Only use subfolder as channelId if it looks like a valid Discord snowflake
370
+ // to prevent nested paths or arbitrary folder names from being treated as IDs.
371
+ const projectChannelId = subfolder && isValidDiscordSnowflake({ value: subfolder })
372
+ ? subfolder
373
+ : undefined;
374
+ const project = projectChannelId
375
+ ? (await resolveChannelName(projectChannelId)) || undefined
376
+ : undefined;
377
+ const upsertResult = await upsertThreadFromFile({
378
+ discordClient,
379
+ forumChannel,
380
+ filePath,
381
+ runtimeState,
382
+ subfolder,
383
+ project,
384
+ projectChannelId,
385
+ });
386
+ // Keep syncing other files even if one file has stale/bad metadata
387
+ // (e.g. threadId that no longer exists). A single bad file should not
388
+ // block watcher startup for the whole memory directory.
389
+ if (upsertResult instanceof Error) {
390
+ forumLogger.warn(`Skipping ${filePath}: ${upsertResult.message}`);
391
+ result.skipped += 1;
392
+ continue;
393
+ }
394
+ if (upsertResult === 'created') {
395
+ result.created += 1;
396
+ }
397
+ else if (upsertResult === 'updated') {
398
+ result.updated += 1;
399
+ }
400
+ else {
401
+ result.skipped += 1;
402
+ }
403
+ }
404
+ for (const filePath of deletedFilePaths || []) {
405
+ const deleteResult = await deleteThreadFromFilePath({
406
+ discordClient,
407
+ forumChannel,
408
+ filePath,
409
+ });
410
+ if (deleteResult instanceof Error) {
411
+ forumLogger.warn(`Skipping delete ${filePath}: ${deleteResult.message}`);
412
+ continue;
413
+ }
414
+ result.deleted += 1;
415
+ }
416
+ return result;
417
+ }
@@ -0,0 +1,190 @@
1
+ // Discord -> filesystem sync.
2
+ // Fetches forum threads from Discord and writes them as markdown files.
3
+ // Handles incremental sync (skip unchanged threads) and stale file cleanup.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createLogger } from '../logger.js';
7
+ import { buildMessageSections, extractProjectChannelFromContent, formatMessageSection, getStringValue, stringifyFrontmatter, } from './markdown.js';
8
+ import { ensureDirectory, fetchForumThreads, fetchThreadMessages, getCanonicalThreadFilePath, loadExistingForumFiles, resolveForumChannel, } from './discord-operations.js';
9
+ import { DEFAULT_RATE_LIMIT_DELAY_MS, ForumSyncOperationError, addIgnoredPath, delay, } from './types.js';
10
+ const forumLogger = createLogger('FORUM');
11
+ function resolveTagNames({ thread, forumChannel, }) {
12
+ const availableTagsById = new Map(forumChannel.availableTags.map((tag) => [tag.id, tag.name]));
13
+ return thread.appliedTags
14
+ .map((tagId) => availableTagsById.get(tagId))
15
+ .filter((tagName) => Boolean(tagName));
16
+ }
17
+ function resolveSubfolderForThread({ existingSubfolder, thread, forumChannel, }) {
18
+ const hasGlobalTag = resolveTagNames({ thread, forumChannel }).some((tagName) => tagName.toLowerCase().trim() === 'global');
19
+ if (hasGlobalTag)
20
+ return 'global';
21
+ if (existingSubfolder)
22
+ return existingSubfolder;
23
+ return undefined;
24
+ }
25
+ function buildFrontmatter({ thread, forumChannel, sections, project, projectChannelId, }) {
26
+ const firstSection = sections[0];
27
+ const createdTimestamp = thread.createdTimestamp ?? Date.now();
28
+ const latestTimestamp = sections.reduce((latest, section) => {
29
+ const created = Date.parse(section.createdAt);
30
+ const edited = section.editedAt ? Date.parse(section.editedAt) : 0;
31
+ return Math.max(latest, created, edited);
32
+ }, createdTimestamp);
33
+ return {
34
+ title: thread.name,
35
+ threadId: thread.id,
36
+ forumChannelId: forumChannel.id,
37
+ tags: resolveTagNames({ thread, forumChannel }),
38
+ author: firstSection?.authorName || '',
39
+ authorId: firstSection?.authorId || '',
40
+ createdAt: thread.createdAt?.toISOString() ||
41
+ new Date(createdTimestamp).toISOString(),
42
+ lastUpdated: new Date(latestTimestamp).toISOString(),
43
+ lastMessageId: thread.lastMessageId,
44
+ lastSyncedAt: new Date().toISOString(),
45
+ messageCount: sections.length,
46
+ ...(project && { project }),
47
+ ...(projectChannelId && { projectChannelId }),
48
+ };
49
+ }
50
+ export async function syncSingleThreadToFile({ thread, forumChannel, outputDir, runtimeState, previousFilePath, subfolder, project, projectChannelId, }) {
51
+ const messages = await fetchThreadMessages({ thread });
52
+ if (messages instanceof Error)
53
+ return messages;
54
+ // Extract projectChannelId from the starter message footer if not already known.
55
+ // This allows Discord -> file sync to reconstruct the correct subfolder
56
+ // even when no local .md file exists (e.g. fresh machine, deleted files).
57
+ let resolvedProjectChannelId = projectChannelId;
58
+ let resolvedSubfolder = subfolder;
59
+ const sections = buildMessageSections({ messages });
60
+ const firstSection = sections[0];
61
+ if (firstSection) {
62
+ const { cleanContent, projectChannelId: footerChannelId } = extractProjectChannelFromContent({ content: firstSection.content });
63
+ firstSection.content = cleanContent;
64
+ if (footerChannelId && !resolvedProjectChannelId) {
65
+ resolvedProjectChannelId = footerChannelId;
66
+ }
67
+ if (resolvedProjectChannelId && !resolvedSubfolder) {
68
+ resolvedSubfolder = resolvedProjectChannelId;
69
+ }
70
+ }
71
+ // Ensure subfolder directory exists when writing into a nested path
72
+ if (resolvedSubfolder) {
73
+ const subDir = path.join(outputDir, resolvedSubfolder);
74
+ const ensureResult = await ensureDirectory({ directory: subDir });
75
+ if (ensureResult instanceof Error)
76
+ return ensureResult;
77
+ }
78
+ const body = sections
79
+ .map((section) => formatMessageSection({ section }))
80
+ .join('\n\n---\n\n');
81
+ const frontmatter = buildFrontmatter({
82
+ thread,
83
+ forumChannel,
84
+ sections,
85
+ project,
86
+ projectChannelId: resolvedProjectChannelId,
87
+ });
88
+ const markdown = stringifyFrontmatter({ frontmatter, body });
89
+ const targetPath = getCanonicalThreadFilePath({
90
+ outputDir,
91
+ threadId: thread.id,
92
+ subfolder: resolvedSubfolder,
93
+ });
94
+ addIgnoredPath({ runtimeState, filePath: targetPath });
95
+ const writeResult = await fs.promises
96
+ .writeFile(targetPath, markdown, 'utf8')
97
+ .catch((cause) => {
98
+ return new ForumSyncOperationError({
99
+ forumChannelId: forumChannel.id,
100
+ reason: `failed to write ${targetPath}`,
101
+ cause,
102
+ });
103
+ });
104
+ if (writeResult instanceof Error)
105
+ return writeResult;
106
+ // Clean up old file if thread was renamed (file path changed)
107
+ if (previousFilePath &&
108
+ previousFilePath !== targetPath &&
109
+ fs.existsSync(previousFilePath)) {
110
+ addIgnoredPath({ runtimeState, filePath: previousFilePath });
111
+ await fs.promises.unlink(previousFilePath).catch((cause) => {
112
+ forumLogger.warn(`Failed to remove old forum file ${previousFilePath}:`, cause);
113
+ });
114
+ }
115
+ }
116
+ export async function syncForumToFiles({ discordClient, forumChannelId, outputDir, forceFullRefresh = false, forceThreadIds, runtimeState, }) {
117
+ const ensureResult = await ensureDirectory({ directory: outputDir });
118
+ if (ensureResult instanceof Error) {
119
+ return new ForumSyncOperationError({
120
+ forumChannelId,
121
+ reason: `failed to create output directory ${outputDir}`,
122
+ cause: ensureResult,
123
+ });
124
+ }
125
+ const forumChannel = await resolveForumChannel({
126
+ discordClient,
127
+ forumChannelId,
128
+ });
129
+ if (forumChannel instanceof Error)
130
+ return forumChannel;
131
+ const threads = await fetchForumThreads({ forumChannel });
132
+ if (threads instanceof Error)
133
+ return threads;
134
+ const existingFiles = await loadExistingForumFiles({ outputDir });
135
+ const existingByThreadId = new Map(existingFiles.map((entry) => [entry.threadId, entry]));
136
+ const result = { synced: 0, skipped: 0, deleted: 0 };
137
+ for (const thread of threads) {
138
+ const existing = existingByThreadId.get(thread.id);
139
+ const savedLastMessageId = getStringValue({ value: existing?.frontmatter.lastMessageId }) || null;
140
+ const isForced = forceFullRefresh || Boolean(forceThreadIds?.has(thread.id));
141
+ if (!isForced &&
142
+ savedLastMessageId &&
143
+ savedLastMessageId === thread.lastMessageId) {
144
+ result.skipped += 1;
145
+ continue;
146
+ }
147
+ const syncResult = await syncSingleThreadToFile({
148
+ thread,
149
+ forumChannel,
150
+ outputDir,
151
+ runtimeState,
152
+ previousFilePath: existing?.filePath,
153
+ subfolder: resolveSubfolderForThread({
154
+ existingSubfolder: existing?.subfolder,
155
+ thread,
156
+ forumChannel,
157
+ }),
158
+ project: getStringValue({ value: existing?.frontmatter.project }),
159
+ projectChannelId: getStringValue({
160
+ value: existing?.frontmatter.projectChannelId,
161
+ }),
162
+ });
163
+ if (syncResult instanceof Error)
164
+ return syncResult;
165
+ result.synced += 1;
166
+ await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS });
167
+ }
168
+ // Delete files for threads that no longer exist in Discord
169
+ const liveThreadIds = new Set(threads.map((thread) => thread.id));
170
+ for (const existing of existingFiles) {
171
+ if (liveThreadIds.has(existing.threadId))
172
+ continue;
173
+ if (!fs.existsSync(existing.filePath))
174
+ continue;
175
+ addIgnoredPath({ runtimeState, filePath: existing.filePath });
176
+ const deleteResult = await fs.promises
177
+ .unlink(existing.filePath)
178
+ .catch((cause) => {
179
+ return new ForumSyncOperationError({
180
+ forumChannelId,
181
+ reason: `failed deleting stale file ${existing.filePath}`,
182
+ cause,
183
+ });
184
+ });
185
+ if (deleteResult instanceof Error)
186
+ return deleteResult;
187
+ result.deleted += 1;
188
+ }
189
+ return result;
190
+ }
@@ -0,0 +1,53 @@
1
+ // Type definitions, tagged errors, and constants for forum sync.
2
+ // All shared types and error classes live here to avoid circular dependencies
3
+ // between the sync modules.
4
+ import * as errore from 'errore';
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // CONSTANTS
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ export const DEFAULT_DEBOUNCE_MS = 800;
9
+ export const DEFAULT_RATE_LIMIT_DELAY_MS = 250;
10
+ export const WRITE_IGNORE_TTL_MS = 2_000;
11
+ // ═══════════════════════════════════════════════════════════════════════════
12
+ // TAGGED ERRORS
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ export class ForumChannelResolveError extends errore.createTaggedError({
15
+ name: 'ForumChannelResolveError',
16
+ message: 'Could not resolve forum channel $forumChannelId',
17
+ }) {
18
+ }
19
+ export class ForumSyncOperationError extends errore.createTaggedError({
20
+ name: 'ForumSyncOperationError',
21
+ message: 'Forum sync operation failed for forum $forumChannelId: $reason',
22
+ }) {
23
+ }
24
+ export class ForumFrontmatterParseError extends errore.createTaggedError({
25
+ name: 'ForumFrontmatterParseError',
26
+ message: 'Failed to parse frontmatter: $reason',
27
+ }) {
28
+ }
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+ // SHARED UTILITIES
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ export function delay({ ms }) {
33
+ return new Promise((resolve) => {
34
+ setTimeout(resolve, ms);
35
+ });
36
+ }
37
+ /** Mark a file path as recently written so the file watcher ignores it. */
38
+ export function addIgnoredPath({ runtimeState, filePath, }) {
39
+ if (!runtimeState)
40
+ return;
41
+ runtimeState.ignoredPaths.set(filePath, Date.now() + WRITE_IGNORE_TTL_MS);
42
+ }
43
+ /** Check if a file path was recently written by us and should be ignored. */
44
+ export function shouldIgnorePath({ runtimeState, filePath, }) {
45
+ const expiresAt = runtimeState.ignoredPaths.get(filePath);
46
+ if (!expiresAt)
47
+ return false;
48
+ if (expiresAt < Date.now()) {
49
+ runtimeState.ignoredPaths.delete(filePath);
50
+ return false;
51
+ }
52
+ return true;
53
+ }