mulmoclaude 0.5.2 → 0.6.0

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 (486) hide show
  1. package/Dockerfile.sandbox +100 -0
  2. package/README.md +17 -4
  3. package/bin/mulmoclaude.js +46 -15
  4. package/bin/prepare-dist.js +18 -2
  5. package/client/assets/chunk-CernVdwh.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-C1eAZMzz.js +1 -0
  7. package/client/assets/html2canvas-CDGcmOD3-BbPeutDg.js +5 -0
  8. package/client/assets/index-BbgSjFQ8.js +4968 -0
  9. package/client/assets/index-ECD0lgIv.css +2 -0
  10. package/client/assets/{index.es-D4YyL_Dg-BgT6a3Nd.js → index.es-DqtpmBm8-DJdTPdnc.js} +5 -5
  11. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +1 -0
  13. package/client/assets/runtime-vue-BVUzgYGA.js +1 -0
  14. package/client/assets/typeof-DBp4T-Ny-C2xoZtcz.js +1 -0
  15. package/client/assets/vue-1e_vz2LW.js +1 -0
  16. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +4 -0
  17. package/client/index.html +33 -2
  18. package/package.json +20 -18
  19. package/sandbox-entrypoint.sh +106 -0
  20. package/server/accounting/accountNormalize.ts +32 -0
  21. package/server/accounting/defaultAccounts.ts +87 -0
  22. package/server/accounting/eventPublisher.ts +51 -0
  23. package/server/accounting/journal.ts +252 -0
  24. package/server/accounting/openingBalances.ts +114 -0
  25. package/server/accounting/report.ts +237 -0
  26. package/server/accounting/service.ts +718 -0
  27. package/server/accounting/snapshotCache.ts +333 -0
  28. package/server/accounting/timeSeries.ts +265 -0
  29. package/server/accounting/types.ts +148 -0
  30. package/server/agent/activeTools.ts +128 -0
  31. package/server/agent/attachmentConverter.ts +10 -5
  32. package/server/agent/backend/claude-code.ts +8 -2
  33. package/server/agent/backend/types.ts +1 -1
  34. package/server/agent/config.ts +101 -31
  35. package/server/agent/index.ts +45 -33
  36. package/server/agent/mcp-server.ts +146 -69
  37. package/server/agent/mcp-tools/index.ts +1 -5
  38. package/server/agent/mcp-tools/notify.ts +2 -22
  39. package/server/agent/mcp-tools/x.ts +0 -4
  40. package/server/agent/mcpHealth.ts +168 -0
  41. package/server/agent/plugin-names.ts +20 -77
  42. package/server/agent/prompt.ts +259 -51
  43. package/server/agent/resumeFailover.ts +1 -1
  44. package/server/agent/stream.ts +0 -1
  45. package/server/api/auth/bearerAuth.ts +5 -5
  46. package/server/api/csrfGuard.ts +1 -1
  47. package/server/api/routes/accounting.ts +366 -0
  48. package/server/api/routes/agent.ts +509 -46
  49. package/server/api/routes/attachment.ts +104 -0
  50. package/server/api/routes/chart.ts +2 -1
  51. package/server/api/routes/config.ts +12 -12
  52. package/server/api/routes/files.ts +105 -48
  53. package/server/api/routes/image.ts +70 -25
  54. package/server/api/routes/journal.ts +35 -0
  55. package/server/api/routes/mulmo-script.ts +358 -118
  56. package/server/api/routes/mulmoScriptValidate.ts +1 -1
  57. package/server/api/routes/news.ts +1 -1
  58. package/server/api/routes/notifications.ts +92 -22
  59. package/server/api/routes/notifier.ts +98 -0
  60. package/server/api/routes/pdf.ts +188 -48
  61. package/server/api/routes/plugins.ts +34 -14
  62. package/server/api/routes/presentHtml.ts +58 -3
  63. package/server/api/routes/roles.ts +1 -8
  64. package/server/api/routes/runtime-plugin.ts +224 -0
  65. package/server/api/routes/scheduler.ts +7 -5
  66. package/server/api/routes/schedulerHandlers.ts +1 -1
  67. package/server/api/routes/schedulerTasks.ts +8 -7
  68. package/server/api/routes/sessions.ts +234 -121
  69. package/server/api/routes/skills.ts +56 -51
  70. package/server/api/routes/sources.ts +52 -45
  71. package/server/api/routes/translation.ts +44 -0
  72. package/server/api/routes/wiki/frontmatter.ts +13 -65
  73. package/server/api/routes/wiki/history.ts +261 -0
  74. package/server/api/routes/wiki/pageIndex.ts +1 -1
  75. package/server/api/routes/wiki.ts +50 -26
  76. package/server/events/file-change.ts +83 -0
  77. package/server/events/notifications.ts +247 -91
  78. package/server/events/pub-sub/index.ts +1 -1
  79. package/server/events/relay-client.ts +5 -5
  80. package/server/events/scheduler-adapter.ts +2 -2
  81. package/server/events/session-store/index.ts +110 -22
  82. package/server/events/task-manager/index.ts +10 -9
  83. package/server/index.ts +509 -33
  84. package/server/notifier/engine.ts +419 -0
  85. package/server/notifier/legacy-adapters.ts +76 -0
  86. package/server/notifier/runtime-api.ts +74 -0
  87. package/server/notifier/store.ts +70 -0
  88. package/server/notifier/types.ts +121 -0
  89. package/server/plugins/dev-loader.ts +171 -0
  90. package/server/plugins/dev-watcher.ts +150 -0
  91. package/server/plugins/diagnostics.ts +188 -0
  92. package/server/plugins/preset-list.ts +52 -0
  93. package/server/plugins/preset-loader.ts +112 -0
  94. package/server/plugins/runtime-chat-api.ts +38 -0
  95. package/server/plugins/runtime-loader.ts +430 -0
  96. package/server/plugins/runtime-registry.ts +112 -0
  97. package/server/plugins/runtime-tasks-api.ts +50 -0
  98. package/server/plugins/runtime.ts +378 -0
  99. package/server/services/translation/cache.ts +72 -0
  100. package/server/services/translation/index.ts +106 -0
  101. package/server/services/translation/llm.ts +140 -0
  102. package/server/services/translation/types.ts +35 -0
  103. package/server/system/credentials.ts +13 -2
  104. package/server/system/env.ts +6 -1
  105. package/server/system/logger/formatters.ts +46 -4
  106. package/server/system/logger/index.ts +4 -4
  107. package/server/system/logger/sinks.ts +26 -5
  108. package/server/system/logger/types.ts +2 -2
  109. package/server/utils/dev-plugin-args.d.mts +11 -0
  110. package/server/utils/dev-plugin-args.mjs +43 -0
  111. package/server/utils/errors.ts +13 -4
  112. package/server/utils/files/accounting-io.ts +295 -0
  113. package/server/utils/files/atomic.ts +17 -49
  114. package/server/utils/files/attachment-store.ts +182 -0
  115. package/server/utils/files/html-io.ts +1 -7
  116. package/server/utils/files/html-store.ts +19 -0
  117. package/server/utils/files/image-store.ts +20 -22
  118. package/server/utils/files/index.ts +5 -15
  119. package/server/utils/files/journal-io.ts +7 -35
  120. package/server/utils/files/json.ts +2 -29
  121. package/server/utils/files/markdown-image-fill.ts +6 -37
  122. package/server/utils/files/markdown-store.ts +6 -21
  123. package/server/utils/files/naming.ts +3 -39
  124. package/server/utils/files/plugins-io.ts +100 -0
  125. package/server/utils/files/reference-dirs-io.ts +1 -9
  126. package/server/utils/files/roles-io.ts +2 -10
  127. package/server/utils/files/safe.ts +17 -19
  128. package/server/utils/files/scheduler-io.ts +1 -7
  129. package/server/utils/files/scheduler-overrides-io.ts +3 -12
  130. package/server/utils/files/session-io.ts +21 -30
  131. package/server/utils/files/spreadsheet-store.ts +9 -22
  132. package/server/utils/files/translation-io.ts +46 -0
  133. package/server/utils/files/user-tasks-io.ts +1 -7
  134. package/server/utils/files/workspace-io.ts +3 -79
  135. package/server/utils/gemini.ts +33 -11
  136. package/server/utils/html/htmlArtifactSplicer.ts +41 -0
  137. package/server/utils/markdown/frontmatter.ts +112 -0
  138. package/server/utils/regex.ts +56 -0
  139. package/server/utils/router.ts +41 -0
  140. package/server/utils/slug.ts +5 -3
  141. package/server/utils/time.ts +12 -0
  142. package/server/workspace/chat-index/indexer.ts +15 -2
  143. package/server/workspace/chat-index/summarizer.ts +1 -1
  144. package/server/workspace/custom-dirs.ts +1 -1
  145. package/server/workspace/helps/gemini.md +1 -1
  146. package/server/workspace/helps/guide.md +61 -0
  147. package/server/workspace/helps/index.md +4 -0
  148. package/server/workspace/helps/presenthtml.md +80 -0
  149. package/server/workspace/helps/sandbox.md +7 -0
  150. package/server/workspace/helps/storyteller.md +101 -0
  151. package/server/workspace/helps/telegram.md +1 -0
  152. package/server/workspace/helps/wiki.md +9 -7
  153. package/server/workspace/journal/archivist-cli.ts +7 -33
  154. package/server/workspace/journal/archivist-schemas.ts +5 -43
  155. package/server/workspace/journal/dailyPass.ts +34 -187
  156. package/server/workspace/journal/diff.ts +3 -28
  157. package/server/workspace/journal/index.ts +10 -81
  158. package/server/workspace/journal/indexFile.ts +3 -24
  159. package/server/workspace/journal/latestDaily.ts +51 -0
  160. package/server/workspace/journal/memoryExtractor.ts +4 -20
  161. package/server/workspace/journal/optimizationPass.ts +4 -21
  162. package/server/workspace/journal/paths.ts +4 -23
  163. package/server/workspace/journal/state.ts +6 -29
  164. package/server/workspace/memory/io.ts +213 -0
  165. package/server/workspace/memory/llm-classifier.ts +158 -0
  166. package/server/workspace/memory/migrate.ts +263 -0
  167. package/server/workspace/memory/run.ts +84 -0
  168. package/server/workspace/memory/topic-cluster.ts +218 -0
  169. package/server/workspace/memory/topic-detect.ts +67 -0
  170. package/server/workspace/memory/topic-index-hook.ts +128 -0
  171. package/server/workspace/memory/topic-io.ts +180 -0
  172. package/server/workspace/memory/topic-migrate.ts +248 -0
  173. package/server/workspace/memory/topic-run.ts +172 -0
  174. package/server/workspace/memory/topic-swap.ts +135 -0
  175. package/server/workspace/memory/topic-types.ts +142 -0
  176. package/server/workspace/memory/types.ts +83 -0
  177. package/server/workspace/news/reader.ts +4 -5
  178. package/server/workspace/paths.ts +124 -47
  179. package/server/workspace/roles.ts +2 -11
  180. package/server/workspace/skills/parser.ts +38 -55
  181. package/server/workspace/skills/user-tasks.ts +1 -2
  182. package/server/workspace/skills-preset/mc-library/SKILL.md +188 -0
  183. package/server/workspace/skills-preset.ts +196 -0
  184. package/server/workspace/sources/fetchers/githubIssues.ts +13 -11
  185. package/server/workspace/sources/fetchers/index.ts +1 -1
  186. package/server/workspace/sources/fetchers/rssParser.ts +1 -1
  187. package/server/workspace/sources/pipeline/index.ts +2 -2
  188. package/server/workspace/sources/pipeline/notify.ts +3 -3
  189. package/server/workspace/sources/pipeline/write.ts +2 -2
  190. package/server/workspace/sources/registry.ts +39 -61
  191. package/server/workspace/sources/robots.ts +1 -1
  192. package/server/workspace/tool-trace/classify.ts +2 -1
  193. package/server/workspace/tool-trace/index.ts +1 -1
  194. package/server/workspace/tool-trace/writeSearch.ts +6 -1
  195. package/server/workspace/wiki-backlinks/index.ts +19 -7
  196. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +1 -0
  197. package/server/workspace/wiki-history/hook/snapshot.mjs +98 -0
  198. package/server/workspace/wiki-history/hook/snapshot.ts +135 -0
  199. package/server/workspace/wiki-history/provision.ts +181 -0
  200. package/server/workspace/wiki-pages/io.ts +217 -0
  201. package/server/workspace/wiki-pages/snapshot.ts +380 -0
  202. package/server/workspace/workspace.ts +75 -13
  203. package/src/App.vue +115 -40
  204. package/src/_runtime/protocol-vue.ts +21 -0
  205. package/src/_runtime/vue.ts +22 -0
  206. package/src/components/ChatInput.vue +14 -10
  207. package/src/components/CopyChatButton.vue +76 -0
  208. package/src/components/FileContentRenderer.vue +67 -14
  209. package/src/components/FileTree.vue +2 -2
  210. package/src/components/FilesView.vue +17 -1
  211. package/src/components/NewsView.vue +16 -2
  212. package/src/components/NotificationBell.vue +320 -93
  213. package/src/components/PageChatComposer.vue +5 -4
  214. package/src/components/PluginLauncher.vue +42 -6
  215. package/src/components/PluginScopedRoot.vue +87 -0
  216. package/src/components/RoleSelector.vue +12 -1
  217. package/src/components/RolesView.vue +562 -0
  218. package/src/components/SentAttachmentChip.vue +102 -0
  219. package/src/components/SessionHistoryPanel.vue +109 -20
  220. package/src/components/SessionRoleIcon.vue +7 -4
  221. package/src/components/SessionSidebar.vue +20 -7
  222. package/src/components/SessionTabBar.vue +1 -1
  223. package/src/components/SettingsMcpTab.vue +4 -4
  224. package/src/components/SettingsModal.vue +2 -0
  225. package/src/components/SidebarHeader.vue +16 -5
  226. package/src/components/SourcesManager.vue +23 -9
  227. package/src/components/SourcesView.vue +1 -1
  228. package/src/components/StackView.vue +102 -6
  229. package/src/components/SuggestionsPanel.vue +105 -16
  230. package/src/components/SystemFileBanner.vue +1 -1
  231. package/src/components/TodoExplorer.vue +4 -5
  232. package/src/components/todo/TodoAddDialog.vue +2 -3
  233. package/src/components/todo/TodoEditDialog.vue +1 -2
  234. package/src/components/todo/TodoEditPanel.vue +2 -3
  235. package/src/components/todo/TodoKanbanView.vue +8 -5
  236. package/src/components/todo/TodoListView.vue +3 -5
  237. package/src/components/todo/TodoTableView.vue +7 -5
  238. package/src/composables/useAccountingChannel.ts +58 -0
  239. package/src/composables/useActiveSession.ts +4 -25
  240. package/src/composables/useAppApi.ts +6 -44
  241. package/src/composables/useClipboardCopy.ts +3 -20
  242. package/src/composables/useContentDisplay.ts +33 -2
  243. package/src/composables/useDevPluginReload.ts +23 -0
  244. package/src/composables/useDynamicFavicon.ts +5 -31
  245. package/src/composables/useEventListeners.ts +0 -20
  246. package/src/composables/useExpandedDirs.ts +4 -15
  247. package/src/composables/useFaviconState.ts +12 -46
  248. package/src/composables/useFileChange.ts +53 -0
  249. package/src/composables/useFreshPluginData.ts +6 -43
  250. package/src/composables/useHealth.ts +14 -43
  251. package/src/composables/useImageErrorRepair.ts +104 -0
  252. package/src/composables/useLatestDaily.ts +40 -0
  253. package/src/composables/useMarkdownDoc.ts +39 -0
  254. package/src/composables/useMarkdownLinkHandler.ts +1 -1
  255. package/src/composables/useMcpTools.ts +3 -16
  256. package/src/composables/useNotifications.ts +138 -112
  257. package/src/composables/usePdfDownload.ts +17 -3
  258. package/src/composables/usePendingCalls.ts +8 -26
  259. package/src/composables/usePluginErrorBoundary.ts +68 -0
  260. package/src/composables/usePubSub.ts +9 -17
  261. package/src/composables/useRunElapsed.ts +5 -22
  262. package/src/composables/useSandboxStatus.ts +4 -20
  263. package/src/composables/useSessionDerived.ts +7 -15
  264. package/src/composables/useSessionHistory.ts +70 -29
  265. package/src/composables/useSessionSync.ts +25 -3
  266. package/src/composables/useSkillsList.ts +59 -0
  267. package/src/composables/useTranslatedQueries.ts +109 -0
  268. package/src/config/apiRoutes.ts +181 -80
  269. package/src/config/historyFilters.ts +5 -3
  270. package/src/config/hostEvents.ts +17 -0
  271. package/src/config/mcpCatalog.ts +277 -5
  272. package/src/config/pubsubChannels.ts +134 -12
  273. package/src/config/roles.ts +212 -147
  274. package/src/config/systemFileDescriptors.ts +5 -5
  275. package/src/config/toolNames.ts +52 -30
  276. package/src/config/workspacePaths.ts +26 -2
  277. package/src/lang/de.ts +483 -27
  278. package/src/lang/en.ts +448 -27
  279. package/src/lang/es.ts +474 -27
  280. package/src/lang/fr.ts +476 -27
  281. package/src/lang/ja.ts +465 -27
  282. package/src/lang/ko.ts +466 -27
  283. package/src/lang/pt-BR.ts +473 -27
  284. package/src/lang/zh.ts +463 -27
  285. package/src/lib/vue-i18n.ts +1 -1
  286. package/src/lib/wiki-page/slug.ts +66 -0
  287. package/src/main.ts +85 -0
  288. package/src/plugins/_extras.ts +58 -0
  289. package/src/plugins/_generated/metas.ts +42 -0
  290. package/src/plugins/_generated/registrations.ts +44 -0
  291. package/src/plugins/_generated/server-bindings.ts +47 -0
  292. package/src/plugins/accounting/Preview.vue +106 -0
  293. package/src/plugins/accounting/View.vue +632 -0
  294. package/src/plugins/accounting/actions.ts +34 -0
  295. package/src/plugins/accounting/api.ts +301 -0
  296. package/src/plugins/accounting/components/AccountEditor.vue +250 -0
  297. package/src/plugins/accounting/components/AccountRow.vue +50 -0
  298. package/src/plugins/accounting/components/AccountsList.vue +102 -0
  299. package/src/plugins/accounting/components/AccountsModal.vue +300 -0
  300. package/src/plugins/accounting/components/BalanceSheet.vue +186 -0
  301. package/src/plugins/accounting/components/BookSettings.vue +284 -0
  302. package/src/plugins/accounting/components/BookSwitcher.vue +78 -0
  303. package/src/plugins/accounting/components/DateRangePicker.vue +140 -0
  304. package/src/plugins/accounting/components/JournalEntryForm.vue +504 -0
  305. package/src/plugins/accounting/components/JournalList.vue +553 -0
  306. package/src/plugins/accounting/components/Ledger.vue +206 -0
  307. package/src/plugins/accounting/components/NewBookForm.vue +211 -0
  308. package/src/plugins/accounting/components/OpeningBalancesForm.vue +271 -0
  309. package/src/plugins/accounting/components/ProfitLoss.vue +160 -0
  310. package/src/plugins/accounting/components/accountDraft.ts +13 -0
  311. package/src/plugins/accounting/components/accountNumbering.ts +103 -0
  312. package/src/plugins/accounting/components/accountValidation.ts +75 -0
  313. package/src/plugins/accounting/components/useLatestRequest.ts +44 -0
  314. package/src/plugins/accounting/countries.ts +158 -0
  315. package/src/plugins/accounting/currencies.ts +64 -0
  316. package/src/plugins/accounting/dates.ts +51 -0
  317. package/src/plugins/accounting/definition.ts +199 -0
  318. package/src/plugins/accounting/fiscalYear.ts +136 -0
  319. package/src/plugins/accounting/index.ts +49 -0
  320. package/src/plugins/accounting/meta.ts +91 -0
  321. package/src/plugins/accounting/timeSeriesEnums.ts +16 -0
  322. package/src/plugins/api.ts +125 -0
  323. package/src/plugins/canvas/View.vue +38 -28
  324. package/src/plugins/canvas/definition.ts +10 -8
  325. package/src/plugins/canvas/index.ts +15 -8
  326. package/src/plugins/canvas/meta.ts +12 -0
  327. package/src/plugins/chart/Preview.vue +1 -1
  328. package/src/plugins/chart/View.vue +2 -2
  329. package/src/plugins/chart/definition.ts +12 -2
  330. package/src/plugins/chart/index.ts +15 -7
  331. package/src/plugins/chart/meta.ts +18 -0
  332. package/src/plugins/editImages/definition.ts +44 -0
  333. package/src/plugins/editImages/index.ts +43 -0
  334. package/src/plugins/editImages/meta.ts +5 -0
  335. package/src/plugins/generateImage/View.vue +3 -1
  336. package/src/plugins/generateImage/definition.ts +2 -0
  337. package/src/plugins/generateImage/index.ts +13 -5
  338. package/src/plugins/generateImage/meta.ts +5 -0
  339. package/src/plugins/index.ts +35 -0
  340. package/src/plugins/manageRoles/Preview.vue +7 -4
  341. package/src/plugins/manageRoles/View.vue +12 -8
  342. package/src/plugins/manageRoles/definition.ts +6 -0
  343. package/src/plugins/manageRoles/index.ts +7 -6
  344. package/src/plugins/manageSkills/View.vue +11 -7
  345. package/src/plugins/manageSkills/definition.ts +4 -1
  346. package/src/plugins/manageSkills/index.ts +14 -7
  347. package/src/plugins/manageSkills/meta.ts +21 -0
  348. package/src/plugins/manageSource/definition.ts +4 -1
  349. package/src/plugins/manageSource/index.ts +15 -7
  350. package/src/plugins/manageSource/meta.ts +21 -0
  351. package/src/plugins/markdown/Preview.vue +10 -8
  352. package/src/plugins/markdown/View.vue +84 -17
  353. package/src/plugins/markdown/definition.ts +7 -1
  354. package/src/plugins/markdown/index.ts +15 -8
  355. package/src/plugins/markdown/meta.ts +16 -0
  356. package/src/plugins/meta-types.ts +97 -0
  357. package/src/plugins/metas.ts +224 -0
  358. package/src/plugins/presentForm/Preview.vue +4 -15
  359. package/src/plugins/presentForm/View.vue +35 -78
  360. package/src/plugins/presentForm/definition.ts +7 -6
  361. package/src/plugins/presentForm/index.ts +12 -5
  362. package/src/plugins/presentForm/meta.ts +11 -0
  363. package/src/plugins/presentForm/plugin.ts +8 -9
  364. package/src/plugins/presentForm/types.ts +0 -24
  365. package/src/plugins/presentHtml/Preview.vue +1 -8
  366. package/src/plugins/presentHtml/View.vue +401 -30
  367. package/src/plugins/presentHtml/definition.ts +8 -5
  368. package/src/plugins/presentHtml/index.ts +15 -8
  369. package/src/plugins/presentHtml/meta.ts +14 -0
  370. package/src/plugins/presentMulmoScript/View.vue +327 -107
  371. package/src/plugins/presentMulmoScript/definition.ts +34 -7
  372. package/src/plugins/presentMulmoScript/helpers.ts +4 -5
  373. package/src/plugins/presentMulmoScript/index.ts +20 -7
  374. package/src/plugins/presentMulmoScript/meta.ts +52 -0
  375. package/src/plugins/scheduler/AutomationsPreview.vue +2 -8
  376. package/src/plugins/scheduler/Preview.vue +5 -2
  377. package/src/plugins/scheduler/TasksTab.vue +16 -36
  378. package/src/plugins/scheduler/View.vue +22 -54
  379. package/src/plugins/scheduler/automationsDefinition.ts +14 -9
  380. package/src/plugins/scheduler/automationsMeta.ts +5 -0
  381. package/src/plugins/scheduler/calendarDefinition.ts +4 -7
  382. package/src/plugins/scheduler/calendarMeta.ts +28 -0
  383. package/src/plugins/scheduler/formatSchedule.ts +6 -24
  384. package/src/plugins/scheduler/index.ts +26 -52
  385. package/src/plugins/scope.ts +57 -0
  386. package/src/plugins/server-bindings-types.ts +38 -0
  387. package/src/plugins/server.ts +32 -0
  388. package/src/plugins/skill/Preview.vue +25 -0
  389. package/src/plugins/skill/View.vue +125 -0
  390. package/src/plugins/skill/definition.ts +23 -0
  391. package/src/plugins/skill/index.ts +36 -0
  392. package/src/plugins/skill/plugin.ts +31 -0
  393. package/src/plugins/skill/types.ts +21 -0
  394. package/src/plugins/spreadsheet/Preview.vue +1 -3
  395. package/src/plugins/spreadsheet/View.vue +29 -49
  396. package/src/plugins/spreadsheet/cellHighlights.ts +2 -3
  397. package/src/plugins/spreadsheet/definition.ts +5 -2
  398. package/src/plugins/spreadsheet/index.ts +15 -8
  399. package/src/plugins/spreadsheet/keyboardNav.ts +38 -0
  400. package/src/plugins/spreadsheet/meta.ts +14 -0
  401. package/src/plugins/textResponse/Preview.vue +9 -1
  402. package/src/plugins/textResponse/View.vue +59 -8
  403. package/src/plugins/textResponse/index.ts +11 -3
  404. package/src/plugins/textResponse/plugin.ts +8 -10
  405. package/src/plugins/textResponse/types.ts +28 -0
  406. package/src/plugins/wiki/Preview.vue +6 -4
  407. package/src/plugins/wiki/View.vue +463 -254
  408. package/src/plugins/wiki/components/WikiPageBody.vue +159 -0
  409. package/src/plugins/wiki/helpers.ts +17 -0
  410. package/src/plugins/wiki/history/HistoryDetail.vue +325 -0
  411. package/src/plugins/wiki/history/HistoryTab.vue +167 -0
  412. package/src/plugins/wiki/history/RestoreConfirm.vue +63 -0
  413. package/src/plugins/wiki/history/api.ts +52 -0
  414. package/src/plugins/wiki/history/diff.ts +145 -0
  415. package/src/plugins/wiki/index.ts +42 -32
  416. package/src/plugins/wiki/meta.ts +10 -0
  417. package/src/plugins/wiki/pageEditLoader.ts +53 -0
  418. package/src/plugins/wiki/route.ts +8 -0
  419. package/src/router/guards.ts +2 -1
  420. package/src/router/index.ts +19 -0
  421. package/src/router/pageRoutes.ts +1 -0
  422. package/src/tools/index.ts +50 -51
  423. package/src/tools/runtimeLoader.ts +141 -0
  424. package/src/tools/types.ts +44 -1
  425. package/src/types/notification.ts +23 -0
  426. package/src/types/pastedFile.ts +10 -0
  427. package/src/types/session.ts +61 -3
  428. package/src/types/sse.ts +21 -6
  429. package/src/utils/agent/eventDispatch.ts +12 -9
  430. package/src/utils/agent/pastedAttachment.ts +35 -0
  431. package/src/utils/agent/request.ts +32 -3
  432. package/src/utils/agent/toolCalls.ts +7 -1
  433. package/src/utils/api.ts +1 -1
  434. package/src/utils/chat/exportMarkdown.ts +243 -0
  435. package/src/utils/errors.ts +10 -2
  436. package/src/utils/files/expandedDirs.ts +1 -1
  437. package/src/utils/filesPreview/todoPreview.ts +13 -2
  438. package/src/utils/format/date.ts +1 -3
  439. package/src/utils/format/jsonSyntax.ts +5 -0
  440. package/src/utils/html/iframeHeightReporterScript.ts +62 -0
  441. package/src/utils/html/previewCsp.ts +29 -2
  442. package/src/utils/image/htmlSrcAttrs.ts +122 -0
  443. package/src/utils/image/imageRepairInlineScript.ts +115 -0
  444. package/src/utils/image/resolve.ts +17 -3
  445. package/src/utils/image/rewriteMarkdownImageRefs.ts +62 -9
  446. package/src/utils/markdown/frontmatter.ts +125 -0
  447. package/src/utils/markdown/taskList.ts +7 -2
  448. package/src/utils/plugin/runtime.ts +132 -0
  449. package/src/utils/session/mergeSessions.ts +40 -37
  450. package/src/utils/session/sessionEntries.ts +74 -18
  451. package/src/utils/session/sessionHelpers.ts +54 -10
  452. package/src/utils/tools/result.ts +76 -14
  453. package/src/vite-env.d.ts +6 -0
  454. package/client/assets/html2canvas-Cx501zZr-Bug0qRNv.js +0 -5
  455. package/client/assets/index-CY-WpQUm.css +0 -2
  456. package/client/assets/index-DbTz2Mfs.js +0 -4911
  457. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  458. package/server/api/routes/html.ts +0 -114
  459. package/server/api/routes/todos.ts +0 -293
  460. package/server/api/routes/todosColumnsHandlers.ts +0 -333
  461. package/server/api/routes/todosHandlers.ts +0 -274
  462. package/server/api/routes/todosItemsHandlers.ts +0 -386
  463. package/server/utils/files/todos-io.ts +0 -29
  464. package/src/components/NotificationToast.vue +0 -75
  465. package/src/plugins/editImage/definition.ts +0 -27
  466. package/src/plugins/editImage/index.ts +0 -37
  467. package/src/plugins/presentHtml/helpers.ts +0 -72
  468. package/src/plugins/scheduler/LegacySchedulerView.vue +0 -32
  469. package/src/plugins/scheduler/legacyShape.ts +0 -34
  470. package/src/plugins/todo/Preview.vue +0 -68
  471. package/src/plugins/todo/View.vue +0 -378
  472. package/src/plugins/todo/composables/useTodos.ts +0 -179
  473. package/src/plugins/todo/definition.ts +0 -45
  474. package/src/plugins/todo/index.ts +0 -62
  475. package/src/plugins/todo/labels.ts +0 -163
  476. package/src/plugins/todo/priority.ts +0 -98
  477. package/src/plugins/todo/viewModes.ts +0 -19
  478. package/src/plugins/wiki/definition.ts +0 -25
  479. package/src/tools/legacyPluginNames.ts +0 -13
  480. package/src/utils/format/frontmatter.ts +0 -80
  481. package/src/utils/image/rewriteHtmlImageRefs.ts +0 -50
  482. package/src/utils/notification/dispatch.ts +0 -58
  483. /package/client/assets/{purify.es-Fx1Nqyry-BwJECkqS.js → purify.es-Fx1Nqyry-BSVNht6S.js} +0 -0
  484. /package/src/plugins/{editImage → editImages}/Preview.vue +0 -0
  485. /package/src/plugins/{editImage → editImages}/View.vue +0 -0
  486. /package/src/{config/schedulerActions.ts → plugins/scheduler/actions.ts} +0 -0
@@ -23,12 +23,14 @@
23
23
  // For a one-shot "fire one of every target kind" run,
24
24
  // scripts/dev/fire-sample-notifications.sh drives this endpoint.
25
25
  //
26
- // The route is exported as a factory so the host wiring can inject
27
- // the pub-sub publisher and the chat-service push handle without
28
- // this file pulling in either module directly.
26
+ // PR 4 of feat-encore made `publishNotification()` a thin wrapper
27
+ // over the notifier engine, so this route no longer needs injected
28
+ // pubsub / bridge deps bridge push fans out via the legacy
29
+ // adapter subscribed to the engine, and `scheduleTestNotification`
30
+ // just calls the wrapper.
29
31
 
30
32
  import { Router, type Request, type Response } from "express";
31
- import { scheduleTestNotification, type NotificationDeps, type ScheduleNotificationOptions } from "../../events/notifications.js";
33
+ import { scheduleTestNotification, type ScheduleNotificationOptions } from "../../events/notifications.js";
32
34
  import { log } from "../../system/logger/index.js";
33
35
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
34
36
  import {
@@ -37,6 +39,7 @@ import {
37
39
  NOTIFICATION_VIEWS,
38
40
  type NotificationAction,
39
41
  type NotificationKind,
42
+ type NotificationTarget,
40
43
  } from "../../../src/types/notification.js";
41
44
  import { isRecord } from "../../utils/types.js";
42
45
 
@@ -45,7 +48,6 @@ interface TestRequestBody {
45
48
  body?: unknown;
46
49
  delaySeconds?: unknown;
47
50
  transportId?: unknown;
48
- chatId?: unknown;
49
51
  kind?: unknown;
50
52
  action?: unknown;
51
53
  }
@@ -63,22 +65,94 @@ function parseKind(value: unknown): NotificationKind | undefined {
63
65
  return KIND_SET.has(value) ? (value as NotificationKind) : undefined;
64
66
  }
65
67
 
66
- // Loose validator dev-facing so we prefer "pass through what looks
67
- // right" over strict type guards. Only checks that `type` is one of
68
- // the two known literals and, for navigates, that `target.view` is a
69
- // known view. The target's other fields are forwarded verbatim so
70
- // adding new optional fields doesn't require editing this file.
71
- function parseAction(value: unknown): NotificationAction | undefined {
68
+ // `path` / `slug` / `anchor` / etc. arrive as `unknown` from the JSON
69
+ // body. The URL builders in `legacyActionToNavigateTarget` assume each
70
+ // field is `string | undefined`; passing a number through (e.g.
71
+ // `path: 123`) would crash later inside `setTimeout` after the 202
72
+ // is sent when `path.split("/")` runs. So validate per-view here:
73
+ // any required field that isn't a non-empty string, or any optional
74
+ // field that is present but isn't a string, drops the whole action.
75
+ // The notification still fires; it just lands without a click target.
76
+ function isOptionalString(value: unknown): boolean {
77
+ return value === undefined || typeof value === "string";
78
+ }
79
+
80
+ function asOptionalString(value: unknown): string | undefined {
81
+ return typeof value === "string" ? value : undefined;
82
+ }
83
+
84
+ // Per-view validators — split out so the dispatcher stays under the
85
+ // cognitive-complexity threshold. Each validator returns either the
86
+ // typed target or `undefined` for any field that doesn't match the
87
+ // `src/types/notification.ts` discriminated union.
88
+
89
+ function validateChatTarget(value: Record<string, unknown>): NotificationTarget | undefined {
90
+ // sessionId required: chat without it would bounce off the catch-all redirect.
91
+ if (typeof value.sessionId !== "string" || value.sessionId.length === 0) return undefined;
92
+ if (!isOptionalString(value.resultUuid)) return undefined;
93
+ return { view: NOTIFICATION_VIEWS.chat, sessionId: value.sessionId, resultUuid: asOptionalString(value.resultUuid) };
94
+ }
95
+
96
+ function validateTodosTarget(value: Record<string, unknown>): NotificationTarget | undefined {
97
+ if (!isOptionalString(value.itemId)) return undefined;
98
+ return { view: NOTIFICATION_VIEWS.todos, itemId: asOptionalString(value.itemId) };
99
+ }
100
+
101
+ function validateAutomationsTarget(value: Record<string, unknown>): NotificationTarget | undefined {
102
+ if (!isOptionalString(value.taskId)) return undefined;
103
+ return { view: NOTIFICATION_VIEWS.automations, taskId: asOptionalString(value.taskId) };
104
+ }
105
+
106
+ function validateSourcesTarget(value: Record<string, unknown>): NotificationTarget | undefined {
107
+ if (!isOptionalString(value.slug)) return undefined;
108
+ return { view: NOTIFICATION_VIEWS.sources, slug: asOptionalString(value.slug) };
109
+ }
110
+
111
+ function validateFilesTarget(value: Record<string, unknown>): NotificationTarget | undefined {
112
+ if (!isOptionalString(value.path)) return undefined;
113
+ return { view: NOTIFICATION_VIEWS.files, path: asOptionalString(value.path) };
114
+ }
115
+
116
+ function validateWikiTarget(value: Record<string, unknown>): NotificationTarget | undefined {
117
+ if (!isOptionalString(value.slug)) return undefined;
118
+ if (!isOptionalString(value.anchor)) return undefined;
119
+ return { view: NOTIFICATION_VIEWS.wiki, slug: asOptionalString(value.slug), anchor: asOptionalString(value.anchor) };
120
+ }
121
+
122
+ function validateNavigateTarget(value: unknown): NotificationTarget | undefined {
123
+ if (!isRecord(value)) return undefined;
124
+ if (typeof value.view !== "string" || !VIEW_SET.has(value.view)) return undefined;
125
+ switch (value.view) {
126
+ case NOTIFICATION_VIEWS.chat:
127
+ return validateChatTarget(value);
128
+ case NOTIFICATION_VIEWS.todos:
129
+ return validateTodosTarget(value);
130
+ case NOTIFICATION_VIEWS.calendar:
131
+ return { view: NOTIFICATION_VIEWS.calendar };
132
+ case NOTIFICATION_VIEWS.automations:
133
+ return validateAutomationsTarget(value);
134
+ case NOTIFICATION_VIEWS.sources:
135
+ return validateSourcesTarget(value);
136
+ case NOTIFICATION_VIEWS.files:
137
+ return validateFilesTarget(value);
138
+ case NOTIFICATION_VIEWS.wiki:
139
+ return validateWikiTarget(value);
140
+ default:
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /** Exported for unit tests. Production callers go through
146
+ * `createNotificationsRouter` and never see this function directly. */
147
+ export function parseAction(value: unknown): NotificationAction | undefined {
72
148
  if (!isRecord(value)) return undefined;
73
149
  if (value.type === NOTIFICATION_ACTION_TYPES.none) {
74
150
  return { type: NOTIFICATION_ACTION_TYPES.none };
75
151
  }
76
152
  if (value.type !== NOTIFICATION_ACTION_TYPES.navigate) return undefined;
77
- const target = value.target;
78
- if (!isRecord(target) || typeof target.view !== "string" || !VIEW_SET.has(target.view)) {
79
- return undefined;
80
- }
81
- return { type: NOTIFICATION_ACTION_TYPES.navigate, target: target as NotificationAction extends { target: infer T } ? T : never };
153
+ const target = validateNavigateTarget(value.target);
154
+ if (!target) return undefined;
155
+ return { type: NOTIFICATION_ACTION_TYPES.navigate, target };
82
156
  }
83
157
 
84
158
  function parseBody(body: TestRequestBody): ScheduleNotificationOptions {
@@ -95,9 +169,6 @@ function parseBody(body: TestRequestBody): ScheduleNotificationOptions {
95
169
  if (typeof body.transportId === "string" && body.transportId.length > 0) {
96
170
  opts.transportId = body.transportId;
97
171
  }
98
- if (typeof body.chatId === "string" && body.chatId.length > 0) {
99
- opts.chatId = body.chatId;
100
- }
101
172
  const kind = parseKind(body.kind);
102
173
  if (kind) opts.kind = kind;
103
174
  const action = parseAction(body.action);
@@ -105,16 +176,15 @@ function parseBody(body: TestRequestBody): ScheduleNotificationOptions {
105
176
  return opts;
106
177
  }
107
178
 
108
- export function createNotificationsRouter(deps: NotificationDeps): Router {
179
+ export function createNotificationsRouter(): Router {
109
180
  const router = Router();
110
181
  router.post(API_ROUTES.notifications.test, (req: Request<object, unknown, TestRequestBody>, res: Response<TestResponse>) => {
111
182
  const opts = parseBody(req.body ?? {});
112
- const scheduled = scheduleTestNotification(opts, deps);
183
+ const scheduled = scheduleTestNotification(opts);
113
184
  log.info("notifications", "scheduled test push", {
114
185
  delaySeconds: scheduled.delaySeconds,
115
186
  firesAt: scheduled.firesAt,
116
187
  transportId: opts.transportId,
117
- chatId: opts.chatId,
118
188
  });
119
189
  res.status(202).json({
120
190
  firesAt: scheduled.firesAt,
@@ -0,0 +1,98 @@
1
+ // Single dispatch endpoint for the host UI's bell popup. Body shape:
2
+ // `{ action: "clear" | "cancel" | "list" | "listHistory", ... }`.
3
+ //
4
+ // Trust boundary — `publish` is INTENTIONALLY not an HTTP action.
5
+ // The only legitimate publishers are in-process: plugins go through
6
+ // `runtime.notifier.publish` (auto-binds `pluginPkg` to the calling
7
+ // plugin's pkg name in `server/plugins/runtime.ts:makeScopedNotifier`)
8
+ // and host-internal modules call `engine.publish` directly. Exposing
9
+ // `publish` over HTTP would let any holder of the bearer token
10
+ // publish under any plugin's namespace, since the route layer cannot
11
+ // authenticate which plugin (if any) made the request — bearer auth
12
+ // only proves "the caller is on this machine and knows the token,"
13
+ // not "the caller is plugin X." If a future feature genuinely needs
14
+ // remote publish, it must arrive with caller-identity headers and a
15
+ // per-pkg auth check; until then this surface is host-UI-only.
16
+ //
17
+ // `clear` / `cancel` are deliberately host-scoped (no `pluginPkg`):
18
+ // the bell popup belongs to the host, sees every plugin's entries,
19
+ // and must be able to dismiss any of them. Plugin-scoped clears (the
20
+ // per-plugin isolation property) live on the in-process runtime API
21
+ // only — `engine.clearForPlugin` is not reachable from this route.
22
+
23
+ import { Router, type Request, type Response } from "express";
24
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
25
+ import { cancel, clear, listAll, listHistory } from "../../notifier/engine.js";
26
+ import type { NotifierEntry, NotifierHistoryEntry } from "../../notifier/types.js";
27
+ import { log } from "../../system/logger/index.js";
28
+
29
+ interface DispatchBody {
30
+ action?: unknown;
31
+ // clear / cancel
32
+ id?: unknown;
33
+ }
34
+
35
+ type DispatchResponse = { ok: true } | { entries: NotifierEntry[] } | { history: NotifierHistoryEntry[] } | { error: string };
36
+
37
+ function isNonEmptyString(value: unknown): value is string {
38
+ return typeof value === "string" && value.length > 0;
39
+ }
40
+
41
+ const notifierRouter: Router = Router();
42
+
43
+ notifierRouter.post(API_ROUTES.notifier.dispatch, async (req: Request<object, DispatchResponse, DispatchBody>, res: Response<DispatchResponse>) => {
44
+ const body = req.body ?? {};
45
+ const { action } = body;
46
+ try {
47
+ switch (action) {
48
+ case "clear": {
49
+ if (!isNonEmptyString(body.id)) {
50
+ res.status(400).json({ error: "id required" });
51
+ return;
52
+ }
53
+ await clear(body.id);
54
+ res.json({ ok: true });
55
+ return;
56
+ }
57
+ case "cancel": {
58
+ if (!isNonEmptyString(body.id)) {
59
+ res.status(400).json({ error: "id required" });
60
+ return;
61
+ }
62
+ await cancel(body.id);
63
+ res.json({ ok: true });
64
+ return;
65
+ }
66
+ case "list": {
67
+ const entries = await listAll();
68
+ res.json({ entries });
69
+ return;
70
+ }
71
+ case "listHistory": {
72
+ const history = await listHistory();
73
+ res.json({ history });
74
+ return;
75
+ }
76
+ // `publish` falls through to the default handler. See the
77
+ // trust-boundary note at the top of this file — exposing it
78
+ // over HTTP would let any bearer-token holder spoof an
79
+ // arbitrary `pluginPkg`. Use the in-process runtime API.
80
+ default:
81
+ res.status(400).json({ error: `unknown action: ${typeof action === "string" ? action : "<missing>"}` });
82
+ }
83
+ } catch (err) {
84
+ // Detailed error stays in the server log for triage; the HTTP
85
+ // response gets an opaque message so a parser-thrown filesystem
86
+ // path / internal stack frame can't leak to the client. Echoing
87
+ // `String(err)` would have given e.g. `Error: ENOENT, open
88
+ // '/Users/<...>/active.json'` to anyone holding the bearer token
89
+ // (CodeRabbit review on PR #1196).
90
+ log.error("notifier-route", "dispatch failed", {
91
+ action: typeof action === "string" ? action : "<unknown>",
92
+ error: String(err),
93
+ });
94
+ res.status(500).json({ error: "internal error" });
95
+ }
96
+ });
97
+
98
+ export default notifierRouter;
@@ -8,8 +8,10 @@ import { badRequest, serverError } from "../../utils/httpError.js";
8
8
  import { WORKSPACE_DIRS } from "../../workspace/paths.js";
9
9
  import { resolveWithinRoot, readBinarySafeSync } from "../../utils/files/safe.js";
10
10
  import { resolveWorkspacePath } from "../../utils/files/workspace-io.js";
11
+ import { parseFrontmatter } from "../../utils/markdown/frontmatter.js";
11
12
  import { log } from "../../system/logger/index.js";
12
13
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
14
+ import { transformResolvableUrlsInHtml } from "../../../src/utils/image/htmlSrcAttrs.js";
13
15
 
14
16
  const router = Router();
15
17
 
@@ -58,54 +60,160 @@ const MIME_BY_EXT: Record<string, string> = {
58
60
  // inside the workspace after symlink resolution.
59
61
  const defaultWorkspaceRoot = realpathSync(resolveWorkspacePath(""));
60
62
 
63
+ export interface InlineImagesOptions {
64
+ /** Workspace root absolute path. Defaults to the lazily-resolved
65
+ * realpath of the configured workspace. */
66
+ workspaceRoot?: string;
67
+ /** Workspace-relative directory the markdown source lives in,
68
+ * used to resolve `../foo.png`-style references. e.g.
69
+ * `"data/wiki/pages"` for Wiki page PDFs. Defaults to
70
+ * `WORKSPACE_DIRS.markdowns` for legacy callers. Inputs are
71
+ * rejected if they're absolute or contain `..` segments — the
72
+ * workspace boundary is enforced anyway by `resolveWithinRoot`,
73
+ * but rejecting up-front gives a clearer log line than a
74
+ * silently-broken image. */
75
+ sourceDir?: string;
76
+ }
77
+
78
+ // Tag scanning + attribute iteration live in the shared helper
79
+ // (`src/utils/image/htmlSrcAttrs.ts`) so the Markdown surface
80
+ // (`rewriteImgSrcAttrsInHtml`) and this PDF surface stay in lockstep.
81
+ // Adding a tag / attribute (Stage B's `<source>` / `<video poster>`
82
+ // / `<audio src>`) updates both surfaces with one diff (#1011 Stage B).
83
+
84
+ function isSafeSourceDir(dir: string): boolean {
85
+ if (!dir) return true;
86
+ if (path.isAbsolute(dir)) return false;
87
+ return !dir.split(/[/\\]/).some((segment) => segment === "..");
88
+ }
89
+
90
+ // Resolve a workspace-rooted-or-relative `src` value to an absolute
91
+ // path on disk, validated to stay inside the workspace root. Returns
92
+ // null on any failure (escape attempt, missing file, malformed path).
93
+ // Logs the reason so the developer can grep when a PDF image is
94
+ // missing.
95
+ function resolveImageAbsPath(src: string, workspaceRoot: string, baseDir: string): string | null {
96
+ // Strip query / fragment before any filesystem-level resolution —
97
+ // a `<img src="foo.png?v=123">` cache-bust must still find the
98
+ // file at `foo.png` on disk. Without this strip, `path.resolve`
99
+ // bakes the `?v=123` into the candidate path and the safe-resolve
100
+ // / readBinarySafeSync reject (codex review iter-2 #1028).
101
+ const pathPart = urlPathname(src);
102
+ // LLM-generated HTML often emits leading-slash workspace-rooted
103
+ // paths like "/artifacts/images/2026/04/foo.png" (web convention).
104
+ // Treat those as workspace-relative; otherwise path.resolve below
105
+ // sees the slash as host-absolute and the safe-resolve rejects.
106
+ const workspaceRooted = pathPart.startsWith("/");
107
+ const resolveBase = workspaceRooted ? workspaceRoot : baseDir;
108
+ const relSrc = workspaceRooted ? pathPart.slice(1) : pathPart;
109
+ const unsafeAbs = path.resolve(resolveBase, relSrc);
110
+ const relToWorkspace = path.relative(workspaceRoot, unsafeAbs);
111
+ if (relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
112
+ log.warn("pdf", "image path escapes workspace", { src });
113
+ return null;
114
+ }
115
+ const abs = resolveWithinRoot(workspaceRoot, relToWorkspace);
116
+ if (!abs) {
117
+ log.warn("pdf", "image path rejected by safe-resolve", { src });
118
+ return null;
119
+ }
120
+ return abs;
121
+ }
122
+
123
+ function loadImageAsDataUri(abs: string): string | null {
124
+ const buf = readBinarySafeSync(abs);
125
+ if (!buf) {
126
+ log.warn("pdf", "could not read image", { abs });
127
+ return null;
128
+ }
129
+ const ext = path.extname(abs).toLowerCase();
130
+ const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
131
+ return `data:${mime};base64,${buf.toString("base64")}`;
132
+ }
133
+
134
+ // Video / audio extensions Stage B (#1011) added to the rewriter.
135
+ // In PDF rendering puppeteer can't play them, so inlining a large
136
+ // `.mp4` as base64 just blows up the HTML and times out the page
137
+ // load (`Navigation timeout of 30000 ms exceeded`). The `<video
138
+ // poster>` attribute IS an image and stays inlined — that's the
139
+ // only thing the user actually sees in a PDF anyway. The `<video
140
+ // src>` / `<source src>` (in a `<video>`) / `<audio src>` URL is
141
+ // left as the original relative path; puppeteer's fetch will fail
142
+ // quickly and `networkidle0` still resolves.
143
+ //
144
+ // Anchored at end-of-pathname (callers strip query / fragment first
145
+ // via `urlPathname` below) so a query-string-only mention of the
146
+ // extension doesn't false-positive — e.g. `foo.png?clip.mp4` must
147
+ // inline the PNG, not be treated as an mp4 (codex review iter-1).
148
+ const PDF_SKIP_MEDIA_EXT_RE = /\.(mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
149
+
150
+ function urlPathname(url: string): string {
151
+ const queryStart = url.indexOf("?");
152
+ const fragStart = url.indexOf("#");
153
+ const limit = [queryStart, fragStart].filter((idx) => idx >= 0).reduce((min, idx) => Math.min(min, idx), url.length);
154
+ return url.slice(0, limit);
155
+ }
156
+
157
+ /** Whether a URL points at a video / audio file that should NOT be
158
+ * base64-inlined into a PDF. Exported for direct unit testing —
159
+ * filesystem-level resolver checks (`resolveImageAbsPath`) can mask
160
+ * the regex's behaviour because a missing file also returns null,
161
+ * so the in-line callback's output looks identical for "skip" vs
162
+ * "resolve-failed". */
163
+ export function shouldSkipMediaForPdf(url: string): boolean {
164
+ return PDF_SKIP_MEDIA_EXT_RE.test(urlPathname(url));
165
+ }
166
+
61
167
  /**
62
- * Inline local images as base64 data URIs so Puppeteer can render them.
63
- * Markdown files live in workspace/artifacts/documents/ and reference
64
- * images as "../images/xyz.png" workspace/artifacts/images/xyz.png.
168
+ * Inline local images as base64 data URIs so Puppeteer can render
169
+ * them. Resolves URL-bearing attributes (currently `<img src>`,
170
+ * `<source src>`, `<video poster|src>`, `<audio src>`) against
171
+ * `sourceDir` (workspace-relative); for example, a Wiki page
172
+ * (`data/wiki/pages/X.md`) referencing
173
+ * `../../../artifacts/images/foo.png` resolves to
174
+ * `artifacts/images/foo.png`.
175
+ *
176
+ * Handles double-quoted, single-quoted, and unquoted values. Skips
177
+ * `data:` URIs and `http(s)` URLs. Refuses values that escape the
178
+ * workspace root after resolution — the workspace boundary is
179
+ * enforced by `resolveWithinRoot`, regardless of `sourceDir`.
65
180
  *
66
- * Paths are validated against the workspace root via resolveWithinRoot
67
- * so an attacker-controlled <img src="../../../etc/passwd"> can't read
68
- * files outside the workspace.
181
+ * Tag + attribute coverage is shared with the browser markdown
182
+ * surface (`rewriteImgSrcAttrsInHtml`) via `RESOLVABLE_TAG_ATTRS` in
183
+ * `src/utils/image/htmlSrcAttrs.ts`.
69
184
  */
70
- export function inlineImages(html: string, workspaceRoot: string = defaultWorkspaceRoot): string {
71
- const baseDir = path.join(workspaceRoot, WORKSPACE_DIRS.markdowns);
72
- return html.replace(/(<img\s[^>]*src=")([^"]+)(")/g, (_match, before: string, src: string, after: string) => {
73
- if (src.startsWith("data:") || src.startsWith("http")) {
74
- return _match;
75
- }
76
- // LLM-generated HTML often emits leading-slash workspace-rooted
77
- // paths like "/artifacts/images/2026/04/foo.png" (web convention).
78
- // Treat those as workspace-relative; otherwise path.resolve below
79
- // sees the slash as host-absolute and the safe-resolve rejects.
80
- const workspaceRooted = src.startsWith("/");
81
- const resolveBase = workspaceRooted ? workspaceRoot : baseDir;
82
- const relSrc = workspaceRooted ? src.slice(1) : src;
83
- // Resolve the image path relative to markdowns/ (or workspace root
84
- // for the leading-slash case) but require the final realpath to
85
- // stay inside the workspace root. markdowns/ references like
86
- // "../images/foo.png" are common so we can't restrict to markdowns/
87
- // itself.
88
- const unsafeAbs = path.resolve(resolveBase, relSrc);
89
- // Make unsafeAbs relative to the workspace for the
90
- // resolveWithinRoot check (it expects a relative path).
91
- const relToWorkspace = path.relative(workspaceRoot, unsafeAbs);
92
- if (relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
93
- log.warn("pdf", "image path escapes workspace", { src });
94
- return _match;
95
- }
96
- const abs = resolveWithinRoot(workspaceRoot, relToWorkspace);
97
- if (!abs) {
98
- log.warn("pdf", "image path rejected by safe-resolve", { src });
99
- return _match;
100
- }
101
- const buf = readBinarySafeSync(abs);
102
- if (!buf) {
103
- log.warn("pdf", "could not read image", { abs });
104
- return _match;
105
- }
106
- const ext = path.extname(abs).toLowerCase();
107
- const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
108
- return `${before}data:${mime};base64,${buf.toString("base64")}${after}`;
185
+ export function inlineImages(html: string, options: InlineImagesOptions = {}): string {
186
+ const workspaceRoot = options.workspaceRoot ?? defaultWorkspaceRoot;
187
+ // Defensive type guard: a malformed request body could send
188
+ // `baseDir: null` / `baseDir: 42` / etc. Coerce anything non-
189
+ // string to undefined so the legacy default kicks in instead of
190
+ // `path.join` throwing on a non-string.
191
+ const requestedDir = typeof options.sourceDir === "string" ? options.sourceDir : undefined;
192
+ // Distinguish "explicitly empty" (= workspace root, e.g. a top-
193
+ // level `README.md`) from "absent" (= legacy `markdowns/` default
194
+ // for chat callers). Without this, the `||` collapse would route
195
+ // every workspace-root file through the legacy default.
196
+ const hasRequestedDir = requestedDir !== undefined;
197
+ const dirIsSafe = !hasRequestedDir || isSafeSourceDir(requestedDir);
198
+ if (hasRequestedDir && !dirIsSafe) {
199
+ log.warn("pdf", "rejecting unsafe sourceDir, falling back to default", { sourceDir: requestedDir });
200
+ }
201
+ const sourceDir = dirIsSafe && hasRequestedDir ? requestedDir : WORKSPACE_DIRS.markdowns;
202
+ const baseDir = path.join(workspaceRoot, sourceDir);
203
+ return transformResolvableUrlsInHtml(html, (url) => {
204
+ // Narrow to exact `http://` / `https://` prefixes so a relative
205
+ // path like `http-assets/logo.png` isn't misclassified as
206
+ // external (CR follow-up on #1023).
207
+ if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) return null;
208
+ // Skip media (mp4 / mp3 / webm / ...) — see PDF_SKIP_MEDIA_EXT_RE
209
+ // comment. `<video poster="x.png">` still inlines because the
210
+ // poster value's pathname ends in an image extension. The
211
+ // pathname slice means `foo.png?cacheBust=clip.mp4` correctly
212
+ // routes through the PNG inline path (codex iter-1).
213
+ if (shouldSkipMediaForPdf(url)) return null;
214
+ const abs = resolveImageAbsPath(url, workspaceRoot, baseDir);
215
+ if (!abs) return null;
216
+ return loadImageAsDataUri(abs);
109
217
  });
110
218
  }
111
219
 
@@ -147,10 +255,41 @@ interface PdfMarkdownBody {
147
255
  markdown: string;
148
256
  filename?: string;
149
257
  format?: "Letter" | "A4";
258
+ /** Workspace-relative source directory of the markdown (e.g.
259
+ * `"data/wiki/pages"` for Wiki pages). Used to resolve relative
260
+ * `<img>` references against the right base. Omit for the legacy
261
+ * `markdowns/` default. Validated server-side; absolute paths
262
+ * and `..` segments are rejected. */
263
+ baseDir?: string;
264
+ /** When true, strip a leading YAML frontmatter envelope before
265
+ * rendering so `title:` / `tags:` etc don't appear as plain text
266
+ * on page 1 of the PDF. Wiki pages use this. Markdown / Text
267
+ * Response callers omit (default false) so a chat-generated
268
+ * document that *literally* starts with `---\n…\n---\n` is
269
+ * preserved verbatim. */
270
+ stripFrontmatter?: boolean;
150
271
  }
151
272
 
152
273
  router.post(API_ROUTES.pdf.markdown, async (req: Request<object, unknown, PdfMarkdownBody>, res: Response) => {
153
- const { markdown, filename = "document.pdf", format = "Letter" } = req.body;
274
+ const { body } = req;
275
+ // Express only sets `req.body` after `express.json()` parses a JSON
276
+ // payload. A client that sends raw JSON `null`, an array, or omits
277
+ // the body entirely would land here with body = null / [] / undefined,
278
+ // and the per-field guards below would throw on the first property
279
+ // dereference. Bail out cleanly with a 400 instead.
280
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
281
+ badRequest(res, "request body must be a JSON object");
282
+ return;
283
+ }
284
+ // Defensive type guards: a malformed JSON body could send
285
+ // `baseDir: null` / `stripFrontmatter: "yes"` / etc. Coerce
286
+ // anything off-shape to its safe default rather than letting a
287
+ // downstream `path.join` / boolean check throw.
288
+ const markdown = typeof body.markdown === "string" ? body.markdown : "";
289
+ const filename = typeof body.filename === "string" ? body.filename : "document.pdf";
290
+ const format: "Letter" | "A4" = body.format === "A4" ? "A4" : "Letter";
291
+ const baseDir = typeof body.baseDir === "string" ? body.baseDir : undefined;
292
+ const stripFrontmatter = body.stripFrontmatter === true;
154
293
 
155
294
  if (!markdown) {
156
295
  badRequest(res, "markdown is required");
@@ -158,8 +297,9 @@ router.post(API_ROUTES.pdf.markdown, async (req: Request<object, unknown, PdfMar
158
297
  }
159
298
 
160
299
  try {
161
- log.info("pdf", "markdown", { filename, length: markdown.length });
162
- const html = inlineImages(await marked.parse(markdown));
300
+ log.info("pdf", "markdown", { filename, length: markdown.length, baseDir, stripFrontmatter });
301
+ const source = stripFrontmatter ? parseFrontmatter(markdown).body : markdown;
302
+ const html = inlineImages(await marked.parse(source), { sourceDir: baseDir });
163
303
  const buffer = await renderPdf(wrapHtml(html, MARKDOWN_CSS), format);
164
304
  sendPdf(res, buffer, filename);
165
305
  } catch (err) {