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.
- package/Dockerfile.sandbox +100 -0
- package/README.md +17 -4
- package/bin/mulmoclaude.js +46 -15
- package/bin/prepare-dist.js +18 -2
- package/client/assets/chunk-CernVdwh.js +1 -0
- package/client/assets/chunk-D8eiyYIV-C1eAZMzz.js +1 -0
- package/client/assets/html2canvas-CDGcmOD3-BbPeutDg.js +5 -0
- package/client/assets/index-BbgSjFQ8.js +4968 -0
- package/client/assets/index-ECD0lgIv.css +2 -0
- package/client/assets/{index.es-D4YyL_Dg-BgT6a3Nd.js → index.es-DqtpmBm8-DJdTPdnc.js} +5 -5
- package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-6WYa8hAs.js +1 -0
- package/client/assets/runtime-vue-BVUzgYGA.js +1 -0
- package/client/assets/typeof-DBp4T-Ny-C2xoZtcz.js +1 -0
- package/client/assets/vue-1e_vz2LW.js +1 -0
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +4 -0
- package/client/index.html +33 -2
- package/package.json +20 -18
- package/sandbox-entrypoint.sh +106 -0
- package/server/accounting/accountNormalize.ts +32 -0
- package/server/accounting/defaultAccounts.ts +87 -0
- package/server/accounting/eventPublisher.ts +51 -0
- package/server/accounting/journal.ts +252 -0
- package/server/accounting/openingBalances.ts +114 -0
- package/server/accounting/report.ts +237 -0
- package/server/accounting/service.ts +718 -0
- package/server/accounting/snapshotCache.ts +333 -0
- package/server/accounting/timeSeries.ts +265 -0
- package/server/accounting/types.ts +148 -0
- package/server/agent/activeTools.ts +128 -0
- package/server/agent/attachmentConverter.ts +10 -5
- package/server/agent/backend/claude-code.ts +8 -2
- package/server/agent/backend/types.ts +1 -1
- package/server/agent/config.ts +101 -31
- package/server/agent/index.ts +45 -33
- package/server/agent/mcp-server.ts +146 -69
- package/server/agent/mcp-tools/index.ts +1 -5
- package/server/agent/mcp-tools/notify.ts +2 -22
- package/server/agent/mcp-tools/x.ts +0 -4
- package/server/agent/mcpHealth.ts +168 -0
- package/server/agent/plugin-names.ts +20 -77
- package/server/agent/prompt.ts +259 -51
- package/server/agent/resumeFailover.ts +1 -1
- package/server/agent/stream.ts +0 -1
- package/server/api/auth/bearerAuth.ts +5 -5
- package/server/api/csrfGuard.ts +1 -1
- package/server/api/routes/accounting.ts +366 -0
- package/server/api/routes/agent.ts +509 -46
- package/server/api/routes/attachment.ts +104 -0
- package/server/api/routes/chart.ts +2 -1
- package/server/api/routes/config.ts +12 -12
- package/server/api/routes/files.ts +105 -48
- package/server/api/routes/image.ts +70 -25
- package/server/api/routes/journal.ts +35 -0
- package/server/api/routes/mulmo-script.ts +358 -118
- package/server/api/routes/mulmoScriptValidate.ts +1 -1
- package/server/api/routes/news.ts +1 -1
- package/server/api/routes/notifications.ts +92 -22
- package/server/api/routes/notifier.ts +98 -0
- package/server/api/routes/pdf.ts +188 -48
- package/server/api/routes/plugins.ts +34 -14
- package/server/api/routes/presentHtml.ts +58 -3
- package/server/api/routes/roles.ts +1 -8
- package/server/api/routes/runtime-plugin.ts +224 -0
- package/server/api/routes/scheduler.ts +7 -5
- package/server/api/routes/schedulerHandlers.ts +1 -1
- package/server/api/routes/schedulerTasks.ts +8 -7
- package/server/api/routes/sessions.ts +234 -121
- package/server/api/routes/skills.ts +56 -51
- package/server/api/routes/sources.ts +52 -45
- package/server/api/routes/translation.ts +44 -0
- package/server/api/routes/wiki/frontmatter.ts +13 -65
- package/server/api/routes/wiki/history.ts +261 -0
- package/server/api/routes/wiki/pageIndex.ts +1 -1
- package/server/api/routes/wiki.ts +50 -26
- package/server/events/file-change.ts +83 -0
- package/server/events/notifications.ts +247 -91
- package/server/events/pub-sub/index.ts +1 -1
- package/server/events/relay-client.ts +5 -5
- package/server/events/scheduler-adapter.ts +2 -2
- package/server/events/session-store/index.ts +110 -22
- package/server/events/task-manager/index.ts +10 -9
- package/server/index.ts +509 -33
- package/server/notifier/engine.ts +419 -0
- package/server/notifier/legacy-adapters.ts +76 -0
- package/server/notifier/runtime-api.ts +74 -0
- package/server/notifier/store.ts +70 -0
- package/server/notifier/types.ts +121 -0
- package/server/plugins/dev-loader.ts +171 -0
- package/server/plugins/dev-watcher.ts +150 -0
- package/server/plugins/diagnostics.ts +188 -0
- package/server/plugins/preset-list.ts +52 -0
- package/server/plugins/preset-loader.ts +112 -0
- package/server/plugins/runtime-chat-api.ts +38 -0
- package/server/plugins/runtime-loader.ts +430 -0
- package/server/plugins/runtime-registry.ts +112 -0
- package/server/plugins/runtime-tasks-api.ts +50 -0
- package/server/plugins/runtime.ts +378 -0
- package/server/services/translation/cache.ts +72 -0
- package/server/services/translation/index.ts +106 -0
- package/server/services/translation/llm.ts +140 -0
- package/server/services/translation/types.ts +35 -0
- package/server/system/credentials.ts +13 -2
- package/server/system/env.ts +6 -1
- package/server/system/logger/formatters.ts +46 -4
- package/server/system/logger/index.ts +4 -4
- package/server/system/logger/sinks.ts +26 -5
- package/server/system/logger/types.ts +2 -2
- package/server/utils/dev-plugin-args.d.mts +11 -0
- package/server/utils/dev-plugin-args.mjs +43 -0
- package/server/utils/errors.ts +13 -4
- package/server/utils/files/accounting-io.ts +295 -0
- package/server/utils/files/atomic.ts +17 -49
- package/server/utils/files/attachment-store.ts +182 -0
- package/server/utils/files/html-io.ts +1 -7
- package/server/utils/files/html-store.ts +19 -0
- package/server/utils/files/image-store.ts +20 -22
- package/server/utils/files/index.ts +5 -15
- package/server/utils/files/journal-io.ts +7 -35
- package/server/utils/files/json.ts +2 -29
- package/server/utils/files/markdown-image-fill.ts +6 -37
- package/server/utils/files/markdown-store.ts +6 -21
- package/server/utils/files/naming.ts +3 -39
- package/server/utils/files/plugins-io.ts +100 -0
- package/server/utils/files/reference-dirs-io.ts +1 -9
- package/server/utils/files/roles-io.ts +2 -10
- package/server/utils/files/safe.ts +17 -19
- package/server/utils/files/scheduler-io.ts +1 -7
- package/server/utils/files/scheduler-overrides-io.ts +3 -12
- package/server/utils/files/session-io.ts +21 -30
- package/server/utils/files/spreadsheet-store.ts +9 -22
- package/server/utils/files/translation-io.ts +46 -0
- package/server/utils/files/user-tasks-io.ts +1 -7
- package/server/utils/files/workspace-io.ts +3 -79
- package/server/utils/gemini.ts +33 -11
- package/server/utils/html/htmlArtifactSplicer.ts +41 -0
- package/server/utils/markdown/frontmatter.ts +112 -0
- package/server/utils/regex.ts +56 -0
- package/server/utils/router.ts +41 -0
- package/server/utils/slug.ts +5 -3
- package/server/utils/time.ts +12 -0
- package/server/workspace/chat-index/indexer.ts +15 -2
- package/server/workspace/chat-index/summarizer.ts +1 -1
- package/server/workspace/custom-dirs.ts +1 -1
- package/server/workspace/helps/gemini.md +1 -1
- package/server/workspace/helps/guide.md +61 -0
- package/server/workspace/helps/index.md +4 -0
- package/server/workspace/helps/presenthtml.md +80 -0
- package/server/workspace/helps/sandbox.md +7 -0
- package/server/workspace/helps/storyteller.md +101 -0
- package/server/workspace/helps/telegram.md +1 -0
- package/server/workspace/helps/wiki.md +9 -7
- package/server/workspace/journal/archivist-cli.ts +7 -33
- package/server/workspace/journal/archivist-schemas.ts +5 -43
- package/server/workspace/journal/dailyPass.ts +34 -187
- package/server/workspace/journal/diff.ts +3 -28
- package/server/workspace/journal/index.ts +10 -81
- package/server/workspace/journal/indexFile.ts +3 -24
- package/server/workspace/journal/latestDaily.ts +51 -0
- package/server/workspace/journal/memoryExtractor.ts +4 -20
- package/server/workspace/journal/optimizationPass.ts +4 -21
- package/server/workspace/journal/paths.ts +4 -23
- package/server/workspace/journal/state.ts +6 -29
- package/server/workspace/memory/io.ts +213 -0
- package/server/workspace/memory/llm-classifier.ts +158 -0
- package/server/workspace/memory/migrate.ts +263 -0
- package/server/workspace/memory/run.ts +84 -0
- package/server/workspace/memory/topic-cluster.ts +218 -0
- package/server/workspace/memory/topic-detect.ts +67 -0
- package/server/workspace/memory/topic-index-hook.ts +128 -0
- package/server/workspace/memory/topic-io.ts +180 -0
- package/server/workspace/memory/topic-migrate.ts +248 -0
- package/server/workspace/memory/topic-run.ts +172 -0
- package/server/workspace/memory/topic-swap.ts +135 -0
- package/server/workspace/memory/topic-types.ts +142 -0
- package/server/workspace/memory/types.ts +83 -0
- package/server/workspace/news/reader.ts +4 -5
- package/server/workspace/paths.ts +124 -47
- package/server/workspace/roles.ts +2 -11
- package/server/workspace/skills/parser.ts +38 -55
- package/server/workspace/skills/user-tasks.ts +1 -2
- package/server/workspace/skills-preset/mc-library/SKILL.md +188 -0
- package/server/workspace/skills-preset.ts +196 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +13 -11
- package/server/workspace/sources/fetchers/index.ts +1 -1
- package/server/workspace/sources/fetchers/rssParser.ts +1 -1
- package/server/workspace/sources/pipeline/index.ts +2 -2
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/write.ts +2 -2
- package/server/workspace/sources/registry.ts +39 -61
- package/server/workspace/sources/robots.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +2 -1
- package/server/workspace/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +6 -1
- package/server/workspace/wiki-backlinks/index.ts +19 -7
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +1 -0
- package/server/workspace/wiki-history/hook/snapshot.mjs +98 -0
- package/server/workspace/wiki-history/hook/snapshot.ts +135 -0
- package/server/workspace/wiki-history/provision.ts +181 -0
- package/server/workspace/wiki-pages/io.ts +217 -0
- package/server/workspace/wiki-pages/snapshot.ts +380 -0
- package/server/workspace/workspace.ts +75 -13
- package/src/App.vue +115 -40
- package/src/_runtime/protocol-vue.ts +21 -0
- package/src/_runtime/vue.ts +22 -0
- package/src/components/ChatInput.vue +14 -10
- package/src/components/CopyChatButton.vue +76 -0
- package/src/components/FileContentRenderer.vue +67 -14
- package/src/components/FileTree.vue +2 -2
- package/src/components/FilesView.vue +17 -1
- package/src/components/NewsView.vue +16 -2
- package/src/components/NotificationBell.vue +320 -93
- package/src/components/PageChatComposer.vue +5 -4
- package/src/components/PluginLauncher.vue +42 -6
- package/src/components/PluginScopedRoot.vue +87 -0
- package/src/components/RoleSelector.vue +12 -1
- package/src/components/RolesView.vue +562 -0
- package/src/components/SentAttachmentChip.vue +102 -0
- package/src/components/SessionHistoryPanel.vue +109 -20
- package/src/components/SessionRoleIcon.vue +7 -4
- package/src/components/SessionSidebar.vue +20 -7
- package/src/components/SessionTabBar.vue +1 -1
- package/src/components/SettingsMcpTab.vue +4 -4
- package/src/components/SettingsModal.vue +2 -0
- package/src/components/SidebarHeader.vue +16 -5
- package/src/components/SourcesManager.vue +23 -9
- package/src/components/SourcesView.vue +1 -1
- package/src/components/StackView.vue +102 -6
- package/src/components/SuggestionsPanel.vue +105 -16
- package/src/components/SystemFileBanner.vue +1 -1
- package/src/components/TodoExplorer.vue +4 -5
- package/src/components/todo/TodoAddDialog.vue +2 -3
- package/src/components/todo/TodoEditDialog.vue +1 -2
- package/src/components/todo/TodoEditPanel.vue +2 -3
- package/src/components/todo/TodoKanbanView.vue +8 -5
- package/src/components/todo/TodoListView.vue +3 -5
- package/src/components/todo/TodoTableView.vue +7 -5
- package/src/composables/useAccountingChannel.ts +58 -0
- package/src/composables/useActiveSession.ts +4 -25
- package/src/composables/useAppApi.ts +6 -44
- package/src/composables/useClipboardCopy.ts +3 -20
- package/src/composables/useContentDisplay.ts +33 -2
- package/src/composables/useDevPluginReload.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +5 -31
- package/src/composables/useEventListeners.ts +0 -20
- package/src/composables/useExpandedDirs.ts +4 -15
- package/src/composables/useFaviconState.ts +12 -46
- package/src/composables/useFileChange.ts +53 -0
- package/src/composables/useFreshPluginData.ts +6 -43
- package/src/composables/useHealth.ts +14 -43
- package/src/composables/useImageErrorRepair.ts +104 -0
- package/src/composables/useLatestDaily.ts +40 -0
- package/src/composables/useMarkdownDoc.ts +39 -0
- package/src/composables/useMarkdownLinkHandler.ts +1 -1
- package/src/composables/useMcpTools.ts +3 -16
- package/src/composables/useNotifications.ts +138 -112
- package/src/composables/usePdfDownload.ts +17 -3
- package/src/composables/usePendingCalls.ts +8 -26
- package/src/composables/usePluginErrorBoundary.ts +68 -0
- package/src/composables/usePubSub.ts +9 -17
- package/src/composables/useRunElapsed.ts +5 -22
- package/src/composables/useSandboxStatus.ts +4 -20
- package/src/composables/useSessionDerived.ts +7 -15
- package/src/composables/useSessionHistory.ts +70 -29
- package/src/composables/useSessionSync.ts +25 -3
- package/src/composables/useSkillsList.ts +59 -0
- package/src/composables/useTranslatedQueries.ts +109 -0
- package/src/config/apiRoutes.ts +181 -80
- package/src/config/historyFilters.ts +5 -3
- package/src/config/hostEvents.ts +17 -0
- package/src/config/mcpCatalog.ts +277 -5
- package/src/config/pubsubChannels.ts +134 -12
- package/src/config/roles.ts +212 -147
- package/src/config/systemFileDescriptors.ts +5 -5
- package/src/config/toolNames.ts +52 -30
- package/src/config/workspacePaths.ts +26 -2
- package/src/lang/de.ts +483 -27
- package/src/lang/en.ts +448 -27
- package/src/lang/es.ts +474 -27
- package/src/lang/fr.ts +476 -27
- package/src/lang/ja.ts +465 -27
- package/src/lang/ko.ts +466 -27
- package/src/lang/pt-BR.ts +473 -27
- package/src/lang/zh.ts +463 -27
- package/src/lib/vue-i18n.ts +1 -1
- package/src/lib/wiki-page/slug.ts +66 -0
- package/src/main.ts +85 -0
- package/src/plugins/_extras.ts +58 -0
- package/src/plugins/_generated/metas.ts +42 -0
- package/src/plugins/_generated/registrations.ts +44 -0
- package/src/plugins/_generated/server-bindings.ts +47 -0
- package/src/plugins/accounting/Preview.vue +106 -0
- package/src/plugins/accounting/View.vue +632 -0
- package/src/plugins/accounting/actions.ts +34 -0
- package/src/plugins/accounting/api.ts +301 -0
- package/src/plugins/accounting/components/AccountEditor.vue +250 -0
- package/src/plugins/accounting/components/AccountRow.vue +50 -0
- package/src/plugins/accounting/components/AccountsList.vue +102 -0
- package/src/plugins/accounting/components/AccountsModal.vue +300 -0
- package/src/plugins/accounting/components/BalanceSheet.vue +186 -0
- package/src/plugins/accounting/components/BookSettings.vue +284 -0
- package/src/plugins/accounting/components/BookSwitcher.vue +78 -0
- package/src/plugins/accounting/components/DateRangePicker.vue +140 -0
- package/src/plugins/accounting/components/JournalEntryForm.vue +504 -0
- package/src/plugins/accounting/components/JournalList.vue +553 -0
- package/src/plugins/accounting/components/Ledger.vue +206 -0
- package/src/plugins/accounting/components/NewBookForm.vue +211 -0
- package/src/plugins/accounting/components/OpeningBalancesForm.vue +271 -0
- package/src/plugins/accounting/components/ProfitLoss.vue +160 -0
- package/src/plugins/accounting/components/accountDraft.ts +13 -0
- package/src/plugins/accounting/components/accountNumbering.ts +103 -0
- package/src/plugins/accounting/components/accountValidation.ts +75 -0
- package/src/plugins/accounting/components/useLatestRequest.ts +44 -0
- package/src/plugins/accounting/countries.ts +158 -0
- package/src/plugins/accounting/currencies.ts +64 -0
- package/src/plugins/accounting/dates.ts +51 -0
- package/src/plugins/accounting/definition.ts +199 -0
- package/src/plugins/accounting/fiscalYear.ts +136 -0
- package/src/plugins/accounting/index.ts +49 -0
- package/src/plugins/accounting/meta.ts +91 -0
- package/src/plugins/accounting/timeSeriesEnums.ts +16 -0
- package/src/plugins/api.ts +125 -0
- package/src/plugins/canvas/View.vue +38 -28
- package/src/plugins/canvas/definition.ts +10 -8
- package/src/plugins/canvas/index.ts +15 -8
- package/src/plugins/canvas/meta.ts +12 -0
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +2 -2
- package/src/plugins/chart/definition.ts +12 -2
- package/src/plugins/chart/index.ts +15 -7
- package/src/plugins/chart/meta.ts +18 -0
- package/src/plugins/editImages/definition.ts +44 -0
- package/src/plugins/editImages/index.ts +43 -0
- package/src/plugins/editImages/meta.ts +5 -0
- package/src/plugins/generateImage/View.vue +3 -1
- package/src/plugins/generateImage/definition.ts +2 -0
- package/src/plugins/generateImage/index.ts +13 -5
- package/src/plugins/generateImage/meta.ts +5 -0
- package/src/plugins/index.ts +35 -0
- package/src/plugins/manageRoles/Preview.vue +7 -4
- package/src/plugins/manageRoles/View.vue +12 -8
- package/src/plugins/manageRoles/definition.ts +6 -0
- package/src/plugins/manageRoles/index.ts +7 -6
- package/src/plugins/manageSkills/View.vue +11 -7
- package/src/plugins/manageSkills/definition.ts +4 -1
- package/src/plugins/manageSkills/index.ts +14 -7
- package/src/plugins/manageSkills/meta.ts +21 -0
- package/src/plugins/manageSource/definition.ts +4 -1
- package/src/plugins/manageSource/index.ts +15 -7
- package/src/plugins/manageSource/meta.ts +21 -0
- package/src/plugins/markdown/Preview.vue +10 -8
- package/src/plugins/markdown/View.vue +84 -17
- package/src/plugins/markdown/definition.ts +7 -1
- package/src/plugins/markdown/index.ts +15 -8
- package/src/plugins/markdown/meta.ts +16 -0
- package/src/plugins/meta-types.ts +97 -0
- package/src/plugins/metas.ts +224 -0
- package/src/plugins/presentForm/Preview.vue +4 -15
- package/src/plugins/presentForm/View.vue +35 -78
- package/src/plugins/presentForm/definition.ts +7 -6
- package/src/plugins/presentForm/index.ts +12 -5
- package/src/plugins/presentForm/meta.ts +11 -0
- package/src/plugins/presentForm/plugin.ts +8 -9
- package/src/plugins/presentForm/types.ts +0 -24
- package/src/plugins/presentHtml/Preview.vue +1 -8
- package/src/plugins/presentHtml/View.vue +401 -30
- package/src/plugins/presentHtml/definition.ts +8 -5
- package/src/plugins/presentHtml/index.ts +15 -8
- package/src/plugins/presentHtml/meta.ts +14 -0
- package/src/plugins/presentMulmoScript/View.vue +327 -107
- package/src/plugins/presentMulmoScript/definition.ts +34 -7
- package/src/plugins/presentMulmoScript/helpers.ts +4 -5
- package/src/plugins/presentMulmoScript/index.ts +20 -7
- package/src/plugins/presentMulmoScript/meta.ts +52 -0
- package/src/plugins/scheduler/AutomationsPreview.vue +2 -8
- package/src/plugins/scheduler/Preview.vue +5 -2
- package/src/plugins/scheduler/TasksTab.vue +16 -36
- package/src/plugins/scheduler/View.vue +22 -54
- package/src/plugins/scheduler/automationsDefinition.ts +14 -9
- package/src/plugins/scheduler/automationsMeta.ts +5 -0
- package/src/plugins/scheduler/calendarDefinition.ts +4 -7
- package/src/plugins/scheduler/calendarMeta.ts +28 -0
- package/src/plugins/scheduler/formatSchedule.ts +6 -24
- package/src/plugins/scheduler/index.ts +26 -52
- package/src/plugins/scope.ts +57 -0
- package/src/plugins/server-bindings-types.ts +38 -0
- package/src/plugins/server.ts +32 -0
- package/src/plugins/skill/Preview.vue +25 -0
- package/src/plugins/skill/View.vue +125 -0
- package/src/plugins/skill/definition.ts +23 -0
- package/src/plugins/skill/index.ts +36 -0
- package/src/plugins/skill/plugin.ts +31 -0
- package/src/plugins/skill/types.ts +21 -0
- package/src/plugins/spreadsheet/Preview.vue +1 -3
- package/src/plugins/spreadsheet/View.vue +29 -49
- package/src/plugins/spreadsheet/cellHighlights.ts +2 -3
- package/src/plugins/spreadsheet/definition.ts +5 -2
- package/src/plugins/spreadsheet/index.ts +15 -8
- package/src/plugins/spreadsheet/keyboardNav.ts +38 -0
- package/src/plugins/spreadsheet/meta.ts +14 -0
- package/src/plugins/textResponse/Preview.vue +9 -1
- package/src/plugins/textResponse/View.vue +59 -8
- package/src/plugins/textResponse/index.ts +11 -3
- package/src/plugins/textResponse/plugin.ts +8 -10
- package/src/plugins/textResponse/types.ts +28 -0
- package/src/plugins/wiki/Preview.vue +6 -4
- package/src/plugins/wiki/View.vue +463 -254
- package/src/plugins/wiki/components/WikiPageBody.vue +159 -0
- package/src/plugins/wiki/helpers.ts +17 -0
- package/src/plugins/wiki/history/HistoryDetail.vue +325 -0
- package/src/plugins/wiki/history/HistoryTab.vue +167 -0
- package/src/plugins/wiki/history/RestoreConfirm.vue +63 -0
- package/src/plugins/wiki/history/api.ts +52 -0
- package/src/plugins/wiki/history/diff.ts +145 -0
- package/src/plugins/wiki/index.ts +42 -32
- package/src/plugins/wiki/meta.ts +10 -0
- package/src/plugins/wiki/pageEditLoader.ts +53 -0
- package/src/plugins/wiki/route.ts +8 -0
- package/src/router/guards.ts +2 -1
- package/src/router/index.ts +19 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/tools/index.ts +50 -51
- package/src/tools/runtimeLoader.ts +141 -0
- package/src/tools/types.ts +44 -1
- package/src/types/notification.ts +23 -0
- package/src/types/pastedFile.ts +10 -0
- package/src/types/session.ts +61 -3
- package/src/types/sse.ts +21 -6
- package/src/utils/agent/eventDispatch.ts +12 -9
- package/src/utils/agent/pastedAttachment.ts +35 -0
- package/src/utils/agent/request.ts +32 -3
- package/src/utils/agent/toolCalls.ts +7 -1
- package/src/utils/api.ts +1 -1
- package/src/utils/chat/exportMarkdown.ts +243 -0
- package/src/utils/errors.ts +10 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/filesPreview/todoPreview.ts +13 -2
- package/src/utils/format/date.ts +1 -3
- package/src/utils/format/jsonSyntax.ts +5 -0
- package/src/utils/html/iframeHeightReporterScript.ts +62 -0
- package/src/utils/html/previewCsp.ts +29 -2
- package/src/utils/image/htmlSrcAttrs.ts +122 -0
- package/src/utils/image/imageRepairInlineScript.ts +115 -0
- package/src/utils/image/resolve.ts +17 -3
- package/src/utils/image/rewriteMarkdownImageRefs.ts +62 -9
- package/src/utils/markdown/frontmatter.ts +125 -0
- package/src/utils/markdown/taskList.ts +7 -2
- package/src/utils/plugin/runtime.ts +132 -0
- package/src/utils/session/mergeSessions.ts +40 -37
- package/src/utils/session/sessionEntries.ts +74 -18
- package/src/utils/session/sessionHelpers.ts +54 -10
- package/src/utils/tools/result.ts +76 -14
- package/src/vite-env.d.ts +6 -0
- package/client/assets/html2canvas-Cx501zZr-Bug0qRNv.js +0 -5
- package/client/assets/index-CY-WpQUm.css +0 -2
- package/client/assets/index-DbTz2Mfs.js +0 -4911
- package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
- package/server/api/routes/html.ts +0 -114
- package/server/api/routes/todos.ts +0 -293
- package/server/api/routes/todosColumnsHandlers.ts +0 -333
- package/server/api/routes/todosHandlers.ts +0 -274
- package/server/api/routes/todosItemsHandlers.ts +0 -386
- package/server/utils/files/todos-io.ts +0 -29
- package/src/components/NotificationToast.vue +0 -75
- package/src/plugins/editImage/definition.ts +0 -27
- package/src/plugins/editImage/index.ts +0 -37
- package/src/plugins/presentHtml/helpers.ts +0 -72
- package/src/plugins/scheduler/LegacySchedulerView.vue +0 -32
- package/src/plugins/scheduler/legacyShape.ts +0 -34
- package/src/plugins/todo/Preview.vue +0 -68
- package/src/plugins/todo/View.vue +0 -378
- package/src/plugins/todo/composables/useTodos.ts +0 -179
- package/src/plugins/todo/definition.ts +0 -45
- package/src/plugins/todo/index.ts +0 -62
- package/src/plugins/todo/labels.ts +0 -163
- package/src/plugins/todo/priority.ts +0 -98
- package/src/plugins/todo/viewModes.ts +0 -19
- package/src/plugins/wiki/definition.ts +0 -25
- package/src/tools/legacyPluginNames.ts +0 -13
- package/src/utils/format/frontmatter.ts +0 -80
- package/src/utils/image/rewriteHtmlImageRefs.ts +0 -50
- package/src/utils/notification/dispatch.ts +0 -58
- /package/client/assets/{purify.es-Fx1Nqyry-BwJECkqS.js → purify.es-Fx1Nqyry-BSVNht6S.js} +0 -0
- /package/src/plugins/{editImage → editImages}/Preview.vue +0 -0
- /package/src/plugins/{editImage → editImages}/View.vue +0 -0
- /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
|
+
}
|