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
@@ -0,0 +1,217 @@
1
+ // Single choke point for `data/wiki/pages/<slug>.md` writes.
2
+ //
3
+ // Every wiki page write — manageWiki MCP tool, the user editing
4
+ // through the file content endpoint, the wiki-backlinks driver
5
+ // appending session links — funnels through `writeWikiPage`.
6
+ // Centralising here gives:
7
+ //
8
+ // - one atomic-write guarantee (was: wiki-backlinks bypassed it)
9
+ // - one place to record edit history (#763 PR 2 — currently a
10
+ // no-op stub; this PR only consolidates the writes)
11
+ // - editor identity captured at the call site (LLM / user /
12
+ // system) where it is actually known. A generic `writeFileAtomic`
13
+ // hook can't tell who originated the edit.
14
+ //
15
+ // PR 1 scope (this commit): consolidation only, behaviour unchanged.
16
+ // PR 2 will fill in `appendSnapshot` with real history pipeline.
17
+ //
18
+ // `appendSnapshot` is a no-op stub on purpose — keeping the call
19
+ // site wired up means PR 2 is purely an internal change.
20
+
21
+ import path from "node:path";
22
+ import { readTextSafe } from "../../utils/files/safe.js";
23
+ import { writeFileAtomic } from "../../utils/files/atomic.js";
24
+ import { mergeFrontmatter, parseFrontmatter, serializeWithFrontmatter } from "../../utils/markdown/frontmatter.js";
25
+ import { isSafeSlug, wikiSlugFromAbsPath } from "../../../src/lib/wiki-page/slug.js";
26
+ import { workspacePath as defaultWorkspacePath } from "../workspace.js";
27
+ import { WORKSPACE_DIRS } from "../paths.js";
28
+ import { appendSnapshot } from "./snapshot.js";
29
+ import { logBackgroundError } from "../../utils/logBackgroundError.js";
30
+
31
+ export type WikiPageEditor = "llm" | "user" | "system";
32
+
33
+ export interface WikiWriteMeta {
34
+ editor: WikiPageEditor;
35
+ /** Chat session that triggered the edit. Optional — not all
36
+ * callers know one (e.g. user save through the file editor). */
37
+ sessionId?: string;
38
+ /** Free-form short reason. LLM-supplied or user-supplied. */
39
+ reason?: string;
40
+ /** Force a snapshot to be recorded even when the body and
41
+ * user-supplied meta haven't changed. Used by the restore
42
+ * route so a "restore to current version" still leaves an
43
+ * audit trail entry — without this the `hasMeaningfulChange`
44
+ * gate would silently swallow the restore (codex iter-1
45
+ * finding). Default: false. */
46
+ forceSnapshot?: boolean;
47
+ }
48
+
49
+ export interface WikiPageWriteOptions {
50
+ /** Override the workspace root for tests. Defaults to the
51
+ * process's resolved workspace (`workspace.ts`). */
52
+ workspaceRoot?: string;
53
+ /** Inject the "now" used for `created` / `updated` frontmatter
54
+ * injection. Tests pass a fixed `Date` so the round-trip is
55
+ * deterministic; production uses the wall clock. */
56
+ now?: () => Date;
57
+ }
58
+
59
+ /** Absolute path for a slug. Throws on slugs that would escape
60
+ * `data/wiki/pages/`. Does not check existence. */
61
+ export function wikiPagePath(slug: string, opts: WikiPageWriteOptions = {}): string {
62
+ if (!isSafeSlug(slug)) {
63
+ throw new Error(`wiki-pages: refusing unsafe slug ${JSON.stringify(slug)}`);
64
+ }
65
+ const root = opts.workspaceRoot ?? defaultWorkspacePath;
66
+ return path.join(root, WORKSPACE_DIRS.wikiPages, `${slug}.md`);
67
+ }
68
+
69
+ /** Read a wiki page; null if missing. Used internally to capture
70
+ * the pre-write content for snapshotting (PR 2). Exposed because
71
+ * some callers want the same null-safe reader. */
72
+ export async function readWikiPage(slug: string, opts: WikiPageWriteOptions = {}): Promise<string | null> {
73
+ return readTextSafe(wikiPagePath(slug, opts));
74
+ }
75
+
76
+ /** Write a wiki page atomically and stamp it with `created` /
77
+ * `updated` / `editor` frontmatter (lazy-on-write — #895 PR B).
78
+ * Existing frontmatter keys are preserved; `created` is set on
79
+ * first write and never overwritten; `updated` is bumped on every
80
+ * write. Callers may pass either a body-only string or content
81
+ * with its own `---\n...\n---` envelope (we re-parse and merge
82
+ * so the resulting file always has a single canonical envelope).
83
+ *
84
+ * `uniqueTmp: true` matches what the generic `/api/files/content`
85
+ * PUT used pre-consolidation — without it two simultaneous writes
86
+ * to the same page collide on the shared `.tmp` staging file
87
+ * (the file-content PUT and the wiki-backlinks driver are
88
+ * independent and may target the same page in the same
89
+ * millisecond).
90
+ *
91
+ * The (old, new) pair still flows into `appendSnapshot` — the
92
+ * no-op stub today, real history pipeline in #763 PR 2. */
93
+ export async function writeWikiPage(slug: string, content: string, meta: WikiWriteMeta, opts: WikiPageWriteOptions = {}): Promise<void> {
94
+ const absPath = wikiPagePath(slug, opts);
95
+ const oldContent = await readTextSafe(absPath);
96
+ const finalContent = stampFrontmatter(oldContent, content, meta, opts);
97
+ await writeFileAtomic(absPath, finalContent, { uniqueTmp: true });
98
+ // Snapshot trigger: only fire when the *body* changed (or the
99
+ // user-supplied meta did) — auto-stamping `updated` on every
100
+ // save would otherwise flood the snapshot store with no-op
101
+ // saves where nothing the user cares about actually changed.
102
+ // Compare bodies after parsing so a frontmatter-only diff in
103
+ // auto-stamped fields doesn't trip the trigger.
104
+ if (meta.forceSnapshot === true || oldContent === null || hasMeaningfulChange(oldContent, finalContent)) {
105
+ // Snapshot failures must NOT fail the page write — the file is
106
+ // already on disk, so surfacing a 500 to the caller would be
107
+ // misleading. Log and move on; the next save will record the
108
+ // next state. Codex review iter-3 #917.
109
+ try {
110
+ await appendSnapshot(slug, oldContent, finalContent, meta, {
111
+ workspaceRoot: opts.workspaceRoot,
112
+ now: opts.now,
113
+ });
114
+ } catch (err) {
115
+ logBackgroundError("wiki-snapshot")(err);
116
+ }
117
+ }
118
+ }
119
+
120
+ /** True iff the diff between `oldContent` and `newContent` is
121
+ * more than just the auto-stamped `updated` / `editor` fields.
122
+ * Auto-stamps land on every save; without this guard the
123
+ * snapshot pipeline (#763 PR 2) would record a snapshot per
124
+ * no-op save. The check compares (body) and (meta minus the
125
+ * auto-stamped keys).
126
+ *
127
+ * Exported so the LLM-write hook callback (`/api/wiki/internal/
128
+ * snapshot`) can apply the same dedupe before recording a
129
+ * snapshot — without this, the hook records one snapshot per
130
+ * Write/Edit even when the LLM only re-stamped `updated`. */
131
+ export function hasMeaningfulChange(oldContent: string, newContent: string): boolean {
132
+ const oldDoc = parseFrontmatter(oldContent);
133
+ const newDoc = parseFrontmatter(newContent);
134
+ if (oldDoc.body !== newDoc.body) return true;
135
+ const oldMeta = withoutAutoStamps(oldDoc.meta);
136
+ const newMeta = withoutAutoStamps(newDoc.meta);
137
+ return JSON.stringify(oldMeta) !== JSON.stringify(newMeta);
138
+ }
139
+
140
+ const AUTO_STAMP_KEYS = new Set(["updated", "editor"]);
141
+
142
+ function withoutAutoStamps(meta: Record<string, unknown>): Record<string, unknown> {
143
+ const out: Record<string, unknown> = {};
144
+ for (const [key, value] of Object.entries(meta)) {
145
+ if (!AUTO_STAMP_KEYS.has(key)) out[key] = value;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** Internal — merge `created` / `updated` / `editor` into the
151
+ * outgoing content. Splits the caller's `content` so a body-only
152
+ * caller and a frontmatter-included caller both produce the
153
+ * same canonical envelope on disk. */
154
+ function stampFrontmatter(oldContent: string | null, newContent: string, meta: WikiWriteMeta, opts: WikiPageWriteOptions): string {
155
+ const existingMeta = oldContent !== null ? parseFrontmatter(oldContent).meta : {};
156
+ const incoming = parseFrontmatter(newContent);
157
+ const now = (opts.now ?? (() => new Date()))();
158
+ const merged = mergeFrontmatter(
159
+ {
160
+ ...existingMeta,
161
+ // Caller's own frontmatter (if they passed any) layers on
162
+ // top of the existing on-disk meta. Callers rarely do this,
163
+ // but when manageWiki sends `---\ntitle: …\n---` we honour it.
164
+ ...incoming.meta,
165
+ },
166
+ {
167
+ // `created` is sticky: keep the existing one if any, else
168
+ // stamp the date (no time — created is "first save day", not
169
+ // "first save instant"). Use `existingMeta.created` so the
170
+ // value isn't reset by an LLM that mistakenly reset it in
171
+ // its incoming frontmatter.
172
+ created: typeof existingMeta.created === "string" && existingMeta.created.length > 0 ? existingMeta.created : toIsoDate(now),
173
+ // `updated` always bumps — full ISO timestamp with ms so
174
+ // same-second writes still order correctly.
175
+ updated: now.toISOString(),
176
+ // `editor` reflects the call-site identity (PR #883). LLM /
177
+ // user disambiguation lives at the API layer; placeholder
178
+ // for now is fine.
179
+ editor: meta.editor,
180
+ },
181
+ );
182
+ return serializeWithFrontmatter(merged, incoming.body);
183
+ }
184
+
185
+ function toIsoDate(date: Date): string {
186
+ // YYYY-MM-DD — sortable, locale-free, matches the issue body's
187
+ // `created: 2026-04-26` example. UTC date deliberately so a
188
+ // session that crosses midnight in the user's TZ doesn't get
189
+ // two different `created` values.
190
+ return date.toISOString().slice(0, 10);
191
+ }
192
+
193
+ /** Routing helper for the generic `/api/files/content` PUT.
194
+ * Returns `{ wiki: true, slug }` when `absPath` resolves directly
195
+ * under `data/wiki/pages/` AND ends in `.md`. Anything outside
196
+ * that exact shape (index.md, sources/, non-md, nested subdirs,
197
+ * paths that escape pagesDir via `..`) is `{ wiki: false }` and
198
+ * should fall back to the generic atomic write.
199
+ *
200
+ * This function is **pure path-string math** — it does no symlink
201
+ * resolution. Callers MUST pass an already-realpath'd `absPath`
202
+ * AND an already-realpath'd `workspaceRoot` (or rely on the
203
+ * default, which mirrors `defaultWorkspacePath`). Mixing one
204
+ * realpath'd side with a symlinked other side is the trap that
205
+ * caused #883 review-iter-1 — a symlinked workspace would have
206
+ * silently routed wiki writes through the generic writer. */
207
+ export function classifyAsWikiPage(absPath: string, opts: WikiPageWriteOptions = {}): { wiki: true; slug: string } | { wiki: false } {
208
+ const root = opts.workspaceRoot ?? defaultWorkspacePath;
209
+ const pagesDir = path.join(root, WORKSPACE_DIRS.wikiPages);
210
+ const slug = wikiSlugFromAbsPath(absPath, pagesDir);
211
+ return slug === null ? { wiki: false } : { wiki: true, slug };
212
+ }
213
+
214
+ // Snapshot pipeline lives in `./snapshot.ts` (#763 PR 2). The
215
+ // indirection keeps `io.ts` focused on the page write contract;
216
+ // snapshot.ts owns retention policy, frontmatter shape, and the
217
+ // history dir layout.
@@ -0,0 +1,380 @@
1
+ // Per-wiki-page edit-history snapshot pipeline (#763 PR 2).
2
+ //
3
+ // Every meaningful save through `writeWikiPage` deposits a
4
+ // snapshot under `data/wiki/.history/<slug>/<stamp>-<shortId>.md`.
5
+ // The file content is byte-identical to what was just written; the
6
+ // snapshot's frontmatter carries `_snapshot_*` keys describing the
7
+ // save itself (timestamp, editor, sessionId, reason).
8
+ //
9
+ // "Restore" reads the snapshot and writes it back through the
10
+ // normal `writeWikiPage` path — no special restore primitive
11
+ // needed, just frontmatter cleanup before the round-trip. This
12
+ // makes restore a *safe, reversible* operation: it adds a new
13
+ // snapshot rather than tearing the history apart.
14
+ //
15
+ // Garbage collection runs on every snapshot append. The retention
16
+ // rule is **OR-keyed**: a snapshot survives as long as it is in
17
+ // the newest 100 OR younger than 180 days; only entries failing
18
+ // BOTH conditions get unlinked. There is no hard cap.
19
+
20
+ import path from "node:path";
21
+ import { promises as fsp, constants as fsConstants, type Dirent } from "node:fs";
22
+ import { writeFileAtomic } from "../../utils/files/atomic.js";
23
+ import { mergeFrontmatter, parseFrontmatter, serializeWithFrontmatter } from "../../utils/markdown/frontmatter.js";
24
+ import { shortId } from "../../utils/id.js";
25
+ import { workspacePath as defaultWorkspacePath } from "../workspace.js";
26
+ import { WORKSPACE_DIRS } from "../paths.js";
27
+ import type { WikiPageEditor, WikiWriteMeta } from "./io.js";
28
+
29
+ export const SNAPSHOT_RETAIN_COUNT = 100;
30
+ export const SNAPSHOT_RETAIN_DAYS = 180;
31
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
32
+
33
+ export interface SnapshotPathOptions {
34
+ workspaceRoot?: string;
35
+ /** Injectable clock for deterministic tests. */
36
+ now?: () => Date;
37
+ /** Injectable id for deterministic tests. */
38
+ shortId?: () => string;
39
+ }
40
+
41
+ /** Directory holding all snapshots for a single slug. Returned even
42
+ * when the dir doesn't exist yet; callers that read should tolerate
43
+ * ENOENT (treat as "no history yet"). */
44
+ export function historyDir(slug: string, opts: SnapshotPathOptions = {}): string {
45
+ const root = opts.workspaceRoot ?? defaultWorkspacePath;
46
+ return path.join(root, WORKSPACE_DIRS.wikiHistory, slug);
47
+ }
48
+
49
+ /** Snapshot summary as surfaced by `listSnapshots` and the history
50
+ * routes. The body is intentionally NOT included so a 100-entry
51
+ * page doesn't blow the response payload — call `readSnapshot` to
52
+ * fetch a single snapshot's full content. */
53
+ export interface SnapshotSummary {
54
+ /** Unique identifier for this snapshot, used as the `:stamp`
55
+ * route param. Shape: `<filenameStamp>-<shortId>`, e.g.
56
+ * `2026-04-28T01-23-45-789Z-abc12345`. The shortId tail is
57
+ * REQUIRED — two saves landing in the same millisecond would
58
+ * otherwise share an identifier and listSnapshots / readSnapshot
59
+ * could return either one nondeterministically (codex iter-1
60
+ * finding). */
61
+ stamp: string;
62
+ /** Bytes of the snapshot file (frontmatter + body, after write). */
63
+ bytes: number;
64
+ ts: string;
65
+ editor: WikiPageEditor;
66
+ sessionId?: string;
67
+ reason?: string;
68
+ }
69
+
70
+ export interface SnapshotContent extends SnapshotSummary {
71
+ /** Frontmatter of the saved page at this snapshot's instant —
72
+ * with `_snapshot_*` keys *included*. Restore strips them. */
73
+ meta: Record<string, unknown>;
74
+ /** Body of the page at the snapshot instant. */
75
+ body: string;
76
+ }
77
+
78
+ // Filenames look like `<filenameStamp>-<shortId>.md`. The
79
+ // filenameStamp is `YYYY-MM-DDTHH-mm-ss-sssZ` (colons swapped to
80
+ // hyphens). The shortId tail disambiguates same-millisecond
81
+ // writes. The public `stamp` identifier (route param) joins both
82
+ // — codex iter-1 noted that exposing only the time part would
83
+ // alias two simultaneous writes.
84
+ const FILENAME_RE = /^(?<filenameStamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-[a-z0-9]+\.md$/i;
85
+
86
+ function timestampToFilenameStamp(date: Date): string {
87
+ // 2026-04-28T01:23:45.789Z → 2026-04-28T01-23-45-789Z. Swap the
88
+ // colons (forbidden on Windows / awkward in URLs) and the period
89
+ // before milliseconds. Result is still strict-monotonic and
90
+ // sortable lexicographically.
91
+ return date.toISOString().replace(/:/g, "-").replace(".", "-");
92
+ }
93
+
94
+ function filenameStampToTimestamp(filenameStamp: string): string | null {
95
+ // Inverse of `timestampToFilenameStamp`. Returns the canonical
96
+ // ISO 8601 form (with colons + period) for use in the
97
+ // _snapshot_ts frontmatter and the JSON wire shape. Returns null
98
+ // when the filenameStamp doesn't match the expected shape —
99
+ // callers can skip the entry rather than throwing on a stray
100
+ // file.
101
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(filenameStamp);
102
+ if (!match) return null;
103
+ const [, date, hour, min, sec, milli] = match;
104
+ return `${date}T${hour}:${min}:${sec}.${milli}Z`;
105
+ }
106
+
107
+ /** Path-safety check for the `:stamp` route param. Accepts the
108
+ * full `<filenameStamp>-<shortId>` form; the bare time-only stamp
109
+ * is rejected because it would alias same-millisecond writes
110
+ * (codex iter-1 finding). */
111
+ export function isSafeStamp(stamp: string): boolean {
112
+ return FILENAME_RE.test(`${stamp}.md`);
113
+ }
114
+
115
+ // Snapshot-meta keys carry strings only, so a plain
116
+ // `Record<string, unknown>` matches `mergeFrontmatter`'s parameter
117
+ // shape exactly. A named interface here would require a cast at
118
+ // the merge call site without buying any extra type safety —
119
+ // snapshot consumers re-validate the shape on read anyway.
120
+ function buildSnapshotMetaPatch(meta: WikiWriteMeta, timestamp: string): Record<string, unknown> {
121
+ const patch: Record<string, unknown> = {
122
+ _snapshot_ts: timestamp,
123
+ _snapshot_editor: meta.editor,
124
+ };
125
+ if (meta.sessionId !== undefined) patch._snapshot_session = meta.sessionId;
126
+ if (meta.reason !== undefined && meta.reason.length > 0) patch._snapshot_reason = meta.reason;
127
+ return patch;
128
+ }
129
+
130
+ const SNAPSHOT_KEYS = ["_snapshot_ts", "_snapshot_editor", "_snapshot_session", "_snapshot_reason"] as const;
131
+
132
+ /** Strip `_snapshot_*` keys from a snapshot's frontmatter so the
133
+ * resulting content can be written back through the normal
134
+ * `writeWikiPage` path without polluting the live page with
135
+ * history-internal metadata. */
136
+ export function stripSnapshotMeta(meta: Record<string, unknown>): Record<string, unknown> {
137
+ const out: Record<string, unknown> = {};
138
+ for (const [key, value] of Object.entries(meta)) {
139
+ if ((SNAPSHOT_KEYS as readonly string[]).includes(key)) continue;
140
+ out[key] = value;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Refuse to operate on a slug whose history dir is a symlink.
146
+ * Returns true when the dir doesn't exist yet (mkdir on first
147
+ * write is fine) OR when it exists as a real directory; returns
148
+ * false when it exists as a symlink (or any other non-dir kind).
149
+ *
150
+ * Both reads AND writes go through this — a directory symlink
151
+ * could otherwise redirect snapshot writes outside the history
152
+ * tree, and reads through it would surface contents from the
153
+ * symlink target (codex review iter-3 / iter-4 #917). */
154
+ async function historyDirIsSafe(dir: string): Promise<boolean> {
155
+ try {
156
+ const stat = await fsp.lstat(dir);
157
+ return stat.isDirectory();
158
+ } catch (err) {
159
+ // Missing dir is fine — appendSnapshot's writeFileAtomic will
160
+ // mkdir-p it on first write. Any other error means we shouldn't
161
+ // touch this path.
162
+ return isErrnoCode(err, "ENOENT");
163
+ }
164
+ }
165
+
166
+ function isErrnoCode(err: unknown, code: string): boolean {
167
+ return typeof err === "object" && err !== null && (err as { code?: unknown }).code === code;
168
+ }
169
+
170
+ /** Write a snapshot file for a page that just changed. The
171
+ * snapshot's content == the page's new content (byte-identical
172
+ * body), with `_snapshot_*` meta merged in. The `_oldContent`
173
+ * parameter is intentionally unused — kept in the signature for
174
+ * symmetry with the call site so a future "diff snapshot" mode
175
+ * doesn't have to thread a new parameter. */
176
+ export async function appendSnapshot(
177
+ slug: string,
178
+ _oldContent: string | null,
179
+ newContent: string,
180
+ meta: WikiWriteMeta,
181
+ opts: SnapshotPathOptions = {},
182
+ ): Promise<string> {
183
+ const now = (opts.now ?? (() => new Date()))();
184
+ const isoTs = now.toISOString();
185
+ const filenameStamp = timestampToFilenameStamp(now);
186
+ const tail = (opts.shortId ?? shortId)();
187
+ const stamp = `${filenameStamp}-${tail}`;
188
+ const fileName = `${stamp}.md`;
189
+
190
+ // The new page already has its own frontmatter (writeWikiPage
191
+ // auto-stamps `created` / `updated` / `editor`). Merge the
192
+ // `_snapshot_*` patch on top so the snapshot file carries both
193
+ // the page's identity AND the save event.
194
+ const parsed = parseFrontmatter(newContent);
195
+ const merged = mergeFrontmatter(parsed.meta, buildSnapshotMetaPatch(meta, isoTs));
196
+ const snapshotContent = serializeWithFrontmatter(merged, parsed.body);
197
+
198
+ const dir = historyDir(slug, opts);
199
+ if (!(await historyDirIsSafe(dir))) {
200
+ throw new Error(`refusing to write snapshot: history dir is a symlink or non-directory (${dir})`);
201
+ }
202
+ await writeFileAtomic(path.join(dir, fileName), snapshotContent);
203
+ await gcSnapshots(slug, now, opts);
204
+ return stamp;
205
+ }
206
+
207
+ /** Walk `historyDir(slug)` and unlink every snapshot that fails
208
+ * BOTH retention rules: outside the newest `SNAPSHOT_RETAIN_COUNT`
209
+ * AND older than `SNAPSHOT_RETAIN_DAYS` from `now`. Idempotent —
210
+ * safe to run on a directory that doesn't exist (no-op).
211
+ * Tolerant of stray files whose names don't match the expected
212
+ * pattern; they are left alone. */
213
+ export async function gcSnapshots(slug: string, now: Date, opts: SnapshotPathOptions = {}): Promise<void> {
214
+ const dir = historyDir(slug, opts);
215
+ const entries = await readSnapshotEntries(dir);
216
+ if (entries.length === 0) return;
217
+
218
+ // Sort newest-first by filenameStamp (the time part). It's
219
+ // lexicographically sortable because it's zero-padded ISO with
220
+ // colons swapped. Same-millisecond writes resolve via the
221
+ // shortId tail in `stamp` for tie-break consistency.
222
+ entries.sort((left, right) => {
223
+ if (left.filenameStamp !== right.filenameStamp) {
224
+ return left.filenameStamp < right.filenameStamp ? 1 : -1;
225
+ }
226
+ return left.stamp < right.stamp ? 1 : left.stamp > right.stamp ? -1 : 0;
227
+ });
228
+
229
+ const cutoffMs = now.getTime() - SNAPSHOT_RETAIN_DAYS * ONE_DAY_MS;
230
+
231
+ await Promise.all(
232
+ entries.map(async (entry, index) => {
233
+ const tsIso = filenameStampToTimestamp(entry.filenameStamp);
234
+ if (tsIso === null) return; // shouldn't happen — readSnapshotEntries already filtered
235
+ const entryMs = Date.parse(tsIso);
236
+ const withinCount = index < SNAPSHOT_RETAIN_COUNT;
237
+ const withinAge = entryMs >= cutoffMs;
238
+ if (withinCount || withinAge) return;
239
+ await fsp.unlink(path.join(dir, entry.fileName)).catch(() => {});
240
+ }),
241
+ );
242
+ }
243
+
244
+ interface SnapshotEntry {
245
+ /** The unique public identifier for this snapshot (filename
246
+ * body without `.md`). Includes the shortId tail so two
247
+ * same-millisecond writes don't alias. */
248
+ stamp: string;
249
+ /** Just the time part of the filename — used to derive the ISO
250
+ * `_snapshot_ts` when the frontmatter doesn't carry one. */
251
+ filenameStamp: string;
252
+ fileName: string;
253
+ }
254
+
255
+ async function readSnapshotEntries(dir: string): Promise<SnapshotEntry[]> {
256
+ // Defence in depth: refuse to read if the directory itself is a
257
+ // symlink (codex review iter-3 / iter-4 #917). See historyDirIsSafe.
258
+ if (!(await historyDirIsSafe(dir))) return [];
259
+
260
+ let dirents: Dirent[];
261
+ try {
262
+ dirents = await fsp.readdir(dir, { withFileTypes: true });
263
+ } catch {
264
+ return [];
265
+ }
266
+ const out: SnapshotEntry[] = [];
267
+ for (const dirent of dirents) {
268
+ // Reject anything that isn't a regular file. Symlinks especially —
269
+ // a malicious actor with workspace write access could plant
270
+ // `<stamp>-<id>.md` as a symlink to /etc/passwd, and history
271
+ // reads would then surface the target through the bearer-authed
272
+ // GET routes (codex review iter-2 #917).
273
+ if (!dirent.isFile()) continue;
274
+ const { name } = dirent;
275
+ const match = FILENAME_RE.exec(name);
276
+ if (!match?.groups) continue;
277
+ out.push({
278
+ stamp: name.slice(0, -".md".length),
279
+ filenameStamp: match.groups.filenameStamp,
280
+ fileName: name,
281
+ });
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /** Open a snapshot file with `O_NOFOLLOW` so the read fails if the
287
+ * path resolves through a symlink. This closes the TOCTOU window
288
+ * between `readdir` (which Dirent-checks the type) and the actual
289
+ * read: even if a workspace writer races to swap the entry into
290
+ * a symlink between the two, the kernel-level open atomically
291
+ * refuses (codex review iter-4 #917). Returns null on any read
292
+ * failure (missing file, symlink, decode error) — callers treat
293
+ * that as "skip this entry". */
294
+ async function readSnapshotFileNoFollow(filePath: string): Promise<{ raw: string; size: number } | null> {
295
+ let handle: import("node:fs/promises").FileHandle | null = null;
296
+ try {
297
+ handle = await fsp.open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
298
+ const raw = await handle.readFile("utf-8");
299
+ const stat = await handle.stat();
300
+ return { raw, size: stat.size };
301
+ } catch {
302
+ return null;
303
+ } finally {
304
+ if (handle) await handle.close().catch(() => {});
305
+ }
306
+ }
307
+
308
+ function entryStringField(meta: Record<string, unknown>, key: string): string | undefined {
309
+ const value = meta[key];
310
+ return typeof value === "string" && value.length > 0 ? value : undefined;
311
+ }
312
+
313
+ function entryEditor(meta: Record<string, unknown>): WikiPageEditor {
314
+ const value = meta._snapshot_editor;
315
+ if (value === "llm" || value === "user" || value === "system") return value;
316
+ // Default to "user" for files written by an older version of the
317
+ // pipeline that didn't stamp the field. Better than throwing on a
318
+ // stray legacy entry.
319
+ return "user";
320
+ }
321
+
322
+ /** List snapshots for a slug, newest-first. Returns an empty array
323
+ * when the slug has no history dir yet. Each entry carries enough
324
+ * meta (ts, editor, reason, sessionId) to render a list view; the
325
+ * body is omitted — call `readSnapshot` for full content. */
326
+ export async function listSnapshots(slug: string, opts: SnapshotPathOptions = {}): Promise<SnapshotSummary[]> {
327
+ const dir = historyDir(slug, opts);
328
+ const entries = await readSnapshotEntries(dir);
329
+ entries.sort((left, right) => {
330
+ if (left.filenameStamp !== right.filenameStamp) {
331
+ return left.filenameStamp < right.filenameStamp ? 1 : -1;
332
+ }
333
+ return left.stamp < right.stamp ? 1 : left.stamp > right.stamp ? -1 : 0;
334
+ });
335
+
336
+ const summaries: SnapshotSummary[] = [];
337
+ for (const entry of entries) {
338
+ const filePath = path.join(dir, entry.fileName);
339
+ const fileData = await readSnapshotFileNoFollow(filePath);
340
+ if (fileData === null) continue;
341
+ const parsed = parseFrontmatter(fileData.raw);
342
+ const tsIso = entryStringField(parsed.meta, "_snapshot_ts") ?? filenameStampToTimestamp(entry.filenameStamp) ?? entry.stamp;
343
+ summaries.push({
344
+ stamp: entry.stamp,
345
+ bytes: fileData.size,
346
+ ts: tsIso,
347
+ editor: entryEditor(parsed.meta),
348
+ sessionId: entryStringField(parsed.meta, "_snapshot_session"),
349
+ reason: entryStringField(parsed.meta, "_snapshot_reason"),
350
+ });
351
+ }
352
+ return summaries;
353
+ }
354
+
355
+ /** Read a single snapshot. Returns null when the file is missing
356
+ * or the stamp is malformed. */
357
+ export async function readSnapshot(slug: string, stamp: string, opts: SnapshotPathOptions = {}): Promise<SnapshotContent | null> {
358
+ if (!isSafeStamp(stamp)) return null;
359
+ const dir = historyDir(slug, opts);
360
+ const entries = await readSnapshotEntries(dir);
361
+ const match = entries.find((entry) => entry.stamp === stamp);
362
+ if (!match) return null;
363
+
364
+ const filePath = path.join(dir, match.fileName);
365
+ const fileData = await readSnapshotFileNoFollow(filePath);
366
+ if (fileData === null) return null;
367
+
368
+ const parsed = parseFrontmatter(fileData.raw);
369
+ const tsIso = entryStringField(parsed.meta, "_snapshot_ts") ?? filenameStampToTimestamp(match.filenameStamp) ?? match.stamp;
370
+ return {
371
+ stamp: match.stamp,
372
+ bytes: fileData.size,
373
+ ts: tsIso,
374
+ editor: entryEditor(parsed.meta),
375
+ sessionId: entryStringField(parsed.meta, "_snapshot_session"),
376
+ reason: entryStringField(parsed.meta, "_snapshot_reason"),
377
+ meta: parsed.meta,
378
+ body: parsed.body,
379
+ };
380
+ }