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,115 @@
|
|
|
1
|
+
// Inline-script flavour of the image-self-repair behaviour the app
|
|
2
|
+
// shell already runs via `useGlobalImageErrorRepair` (see
|
|
3
|
+
// `src/composables/useImageErrorRepair.ts`).
|
|
4
|
+
//
|
|
5
|
+
// Iframe surfaces (presentHtml result, Files HTML preview) live in
|
|
6
|
+
// their own Document, so the parent's document-level error handler
|
|
7
|
+
// can't see their `<img>` 404s. Server-side `inlineImages` (PDF) and
|
|
8
|
+
// the markdown rewriter (browser) cover the deterministic-resolution
|
|
9
|
+
// half of the routing strategy, but iframes that load HTML files
|
|
10
|
+
// directly off `/artifacts/html/...` need a third leg: an in-iframe
|
|
11
|
+
// `error` listener that does the same one-shot repair.
|
|
12
|
+
//
|
|
13
|
+
// This module is the **pure** form (no Vue, no DOM access at module
|
|
14
|
+
// load) so:
|
|
15
|
+
// - server/index.ts can import it for splicing into HTML responses
|
|
16
|
+
// - the composable in `useImageErrorRepair.ts` re-exports it for
|
|
17
|
+
// back-compat with existing test imports
|
|
18
|
+
//
|
|
19
|
+
// Stage 3 of the image-path-routing redesign — see
|
|
20
|
+
// plans/done/feat-image-path-routing.md and #1025.
|
|
21
|
+
|
|
22
|
+
// All Gemini / canvas / image-edit output lives at
|
|
23
|
+
// `artifacts/images/YYYY/MM/<id>.png` (server/utils/files/image-store.ts).
|
|
24
|
+
// If a rendered URL embeds that segment somewhere, trim everything
|
|
25
|
+
// before the pattern and retry as `/artifacts/images/<rest>` — the
|
|
26
|
+
// static mount will then serve the file directly.
|
|
27
|
+
export const IMAGE_REPAIR_PATTERN = /artifacts\/images\/.+/;
|
|
28
|
+
|
|
29
|
+
// Inline script intended for iframe surfaces. Same decision tree as
|
|
30
|
+
// `useGlobalImageErrorRepair`; kept as a string so it can be embedded
|
|
31
|
+
// into the rendered HTML and run inside the iframe. The regex literal
|
|
32
|
+
// is interpolated from `IMAGE_REPAIR_PATTERN` so the two stay in
|
|
33
|
+
// lockstep automatically.
|
|
34
|
+
export const IMAGE_REPAIR_INLINE_SCRIPT = `
|
|
35
|
+
document.addEventListener("error", function (event) {
|
|
36
|
+
const target = event.target;
|
|
37
|
+
if (!target) return;
|
|
38
|
+
const pattern = ${IMAGE_REPAIR_PATTERN.toString()};
|
|
39
|
+
function fixImg(img) {
|
|
40
|
+
if (img.dataset.imageRepairTried) return;
|
|
41
|
+
const m = String(img.src).match(pattern);
|
|
42
|
+
if (!m) return;
|
|
43
|
+
img.dataset.imageRepairTried = "1";
|
|
44
|
+
img.src = "/" + m[0];
|
|
45
|
+
}
|
|
46
|
+
function fixSource(src) {
|
|
47
|
+
if (src.dataset.imageRepairTried) return;
|
|
48
|
+
let changed = false;
|
|
49
|
+
const srcAttr = src.getAttribute("src");
|
|
50
|
+
if (srcAttr) {
|
|
51
|
+
const m = srcAttr.match(pattern);
|
|
52
|
+
if (m) { src.setAttribute("src", "/" + m[0]); changed = true; }
|
|
53
|
+
}
|
|
54
|
+
if (src.srcset) {
|
|
55
|
+
const orig = src.srcset;
|
|
56
|
+
const next = orig.replace(/[^\\s,]+/g, function (tok) {
|
|
57
|
+
const mm = tok.match(pattern);
|
|
58
|
+
return mm ? "/" + mm[0] : tok;
|
|
59
|
+
});
|
|
60
|
+
if (next !== orig) { src.srcset = next; changed = true; }
|
|
61
|
+
}
|
|
62
|
+
if (changed) src.dataset.imageRepairTried = "1";
|
|
63
|
+
}
|
|
64
|
+
if (target.tagName === "IMG") {
|
|
65
|
+
fixImg(target);
|
|
66
|
+
const pic = target.closest && target.closest("picture");
|
|
67
|
+
if (pic) for (const s of pic.querySelectorAll("source")) fixSource(s);
|
|
68
|
+
} else if (target.tagName === "SOURCE") {
|
|
69
|
+
fixSource(target);
|
|
70
|
+
} else if (target.tagName === "AUDIO" || target.tagName === "VIDEO") {
|
|
71
|
+
for (const s of target.querySelectorAll(":scope > source")) fixSource(s);
|
|
72
|
+
}
|
|
73
|
+
}, true);
|
|
74
|
+
`.trim();
|
|
75
|
+
|
|
76
|
+
// Wrap the script body in a `<script>` tag once at module load; the
|
|
77
|
+
// splicer below uses this directly so each splice is a single string
|
|
78
|
+
// concatenation, not a per-request `<script>...</script>` rebuild.
|
|
79
|
+
const IMAGE_REPAIR_SCRIPT_TAG = `<script>${IMAGE_REPAIR_INLINE_SCRIPT}</script>`;
|
|
80
|
+
|
|
81
|
+
// `</body>` (case-insensitive, whitespace-tolerant). The previous
|
|
82
|
+
// implementation paired this with a `(?![\s\S]*<\/body\s*>)` negative
|
|
83
|
+
// lookahead to anchor at the LAST occurrence — but that lookahead is
|
|
84
|
+
// O(N²) on inputs with many `</body>` tokens (an adversarial / unusual
|
|
85
|
+
// input shape, but cheap to defang). The current implementation runs
|
|
86
|
+
// `matchAll` once over the input (linear) and takes the last hit, so
|
|
87
|
+
// the splice point selection is O(N) regardless of input shape.
|
|
88
|
+
const BODY_CLOSE_RE = /<\/body\s*>/gi;
|
|
89
|
+
|
|
90
|
+
/** Splice `<script>${IMAGE_REPAIR_INLINE_SCRIPT}</script>` into an
|
|
91
|
+
* HTML document just before its **last** closing `</body>`.
|
|
92
|
+
* Anchoring at the last close means nested `</body>` inside e.g.
|
|
93
|
+
* literal example text inside `<pre>` doesn't fool us into splicing
|
|
94
|
+
* too early. If the document has no `</body>` (fragments, hand-
|
|
95
|
+
* written HTML), append the tag at the end so the script still
|
|
96
|
+
* loads.
|
|
97
|
+
*
|
|
98
|
+
* Pure string operation — safe to call on any HTML payload, no
|
|
99
|
+
* DOM parsing, no allocation beyond the result string. Linear time
|
|
100
|
+
* in input length even on adversarial inputs (verified by the
|
|
101
|
+
* `processes 100K </body> tokens in linear time` test). Idempotent:
|
|
102
|
+
* calling on already-spliced output appends a second copy (the
|
|
103
|
+
* script is one-shot per element so duplicates are harmless), so
|
|
104
|
+
* callers should splice exactly once per response. */
|
|
105
|
+
export function injectImageRepairScript(html: string): string {
|
|
106
|
+
if (!html) return html;
|
|
107
|
+
// matchAll → spread into an array → take the last entry. One linear
|
|
108
|
+
// pass over the input regardless of how many `</body>` tokens it
|
|
109
|
+
// contains.
|
|
110
|
+
const matches = [...html.matchAll(BODY_CLOSE_RE)];
|
|
111
|
+
if (matches.length === 0) return html + IMAGE_REPAIR_SCRIPT_TAG;
|
|
112
|
+
const idx = matches[matches.length - 1].index;
|
|
113
|
+
if (idx === undefined) return html + IMAGE_REPAIR_SCRIPT_TAG;
|
|
114
|
+
return `${html.slice(0, idx)}${IMAGE_REPAIR_SCRIPT_TAG}${html.slice(idx)}`;
|
|
115
|
+
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { API_ROUTES } from "../../config/apiRoutes";
|
|
2
2
|
import { getImageBump } from "./cacheBust";
|
|
3
3
|
|
|
4
|
+
// Files saved by `saveImage()` (Gemini, canvas, image edit) all live
|
|
5
|
+
// under this prefix — see server/utils/files/image-store.ts and
|
|
6
|
+
// server/workspace/paths.ts (WORKSPACE_DIRS.images). Express mounts a
|
|
7
|
+
// static handler for the corresponding URL so these paths route
|
|
8
|
+
// directly to the file without going through /api/files/raw.
|
|
9
|
+
const IMAGES_DIR_PREFIX = "artifacts/images/";
|
|
10
|
+
|
|
4
11
|
/** Convert an imageData value to a displayable URL.
|
|
5
|
-
* Handles
|
|
12
|
+
* Handles data URIs, paths under `artifacts/images/` (resolved via
|
|
13
|
+
* the static mount), and everything else (resolved via the workspace
|
|
14
|
+
* file server). */
|
|
6
15
|
export function resolveImageSrc(imageData: string): string {
|
|
7
16
|
if (imageData.startsWith("data:")) return imageData;
|
|
17
|
+
if (imageData.startsWith(IMAGES_DIR_PREFIX)) return `/${imageData}`;
|
|
8
18
|
return `${API_ROUTES.files.raw}?path=${encodeURIComponent(imageData)}`;
|
|
9
19
|
}
|
|
10
20
|
|
|
@@ -18,7 +28,11 @@ export function resolveImageSrc(imageData: string): string {
|
|
|
18
28
|
* redraw, which races with stroke painting and blanks the canvas. */
|
|
19
29
|
export function resolveImageSrcFresh(imageData: string): string {
|
|
20
30
|
if (imageData.startsWith("data:")) return imageData;
|
|
21
|
-
const base =
|
|
31
|
+
const base = resolveImageSrc(imageData);
|
|
22
32
|
const bump = getImageBump(imageData);
|
|
23
|
-
|
|
33
|
+
if (bump <= 0) return base;
|
|
34
|
+
// Both URL forms append a cache-bust param. The static mount form
|
|
35
|
+
// uses `?v=`, the API form already has `?path=` so we use `&v=`.
|
|
36
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
37
|
+
return `${base}${sep}v=${bump}`;
|
|
24
38
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { marked } from "marked";
|
|
2
2
|
import type { Token, Tokens } from "marked";
|
|
3
3
|
import { resolveImageSrc } from "./resolve";
|
|
4
|
+
import { transformResolvableUrlsInHtml } from "./htmlSrcAttrs";
|
|
4
5
|
|
|
5
6
|
// Pre-`marked` pass that rewrites workspace-relative image references
|
|
6
7
|
// in markdown source so they render through the backend file server.
|
|
@@ -102,8 +103,43 @@ function rewriteImageToken(token: Tokens.Image, basePath: string): string | null
|
|
|
102
103
|
return ``;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// Rewrite URL-bearing attributes of every recognised tag inside an
|
|
107
|
+
// HTML fragment, applying the same basePath / shouldSkip /
|
|
108
|
+
// resolveImageSrc pipeline used for `` markdown images.
|
|
109
|
+
// Other attributes (alt, class, style, id, …) are preserved verbatim.
|
|
110
|
+
//
|
|
111
|
+
// Tags + attributes covered (single source of truth at
|
|
112
|
+
// `htmlSrcAttrs.ts:RESOLVABLE_TAG_ATTRS`): `<img src>`, `<source src>`,
|
|
113
|
+
// `<video poster|src>`, `<audio src>`. Add a row there to extend
|
|
114
|
+
// coverage; both this rewriter and the server-side PDF rewriter pick
|
|
115
|
+
// it up automatically (#1011 Stage B).
|
|
116
|
+
//
|
|
117
|
+
// Output URLs come from `resolveImageSrc`, which either returns a
|
|
118
|
+
// mount-rooted path (`/artifacts/images/<file>`) or runs the input
|
|
119
|
+
// through `encodeURIComponent`. `"` becomes `%22`, `'` becomes `%27`,
|
|
120
|
+
// `<` / `>` are encoded — the rewritten attribute can't break out of
|
|
121
|
+
// its own quotes or close the tag.
|
|
122
|
+
//
|
|
123
|
+
// Limitations:
|
|
124
|
+
// - `srcset` (comma-separated descriptor list) is deferred —
|
|
125
|
+
// tracked under #1011 Stage B follow-up.
|
|
126
|
+
// - SVG `<image href>` and CSS `url()` are deferred per plan
|
|
127
|
+
// §修正提案 P3-A.
|
|
128
|
+
// - A regex can't perfectly distinguish a real tag from one
|
|
129
|
+
// embedded in another attribute's value; embedded matches get
|
|
130
|
+
// rewritten too. Harmless because the rewritten URL is encoded
|
|
131
|
+
// safely.
|
|
132
|
+
export function rewriteImgSrcAttrsInHtml(html: string, basePath: string): string {
|
|
133
|
+
return transformResolvableUrlsInHtml(html, (url) => {
|
|
134
|
+
if (shouldSkip(url)) return null;
|
|
135
|
+
const resolved = resolveWorkspacePath(basePath, url);
|
|
136
|
+
if (resolved === null) return null;
|
|
137
|
+
return resolveImageSrc(resolved);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
function isSkippable(token: Token): boolean {
|
|
106
|
-
return token.type === "code" || token.type === "codespan"
|
|
142
|
+
return token.type === "code" || token.type === "codespan";
|
|
107
143
|
}
|
|
108
144
|
|
|
109
145
|
function getContainerChildren(token: Token): Token[] | null {
|
|
@@ -135,11 +171,14 @@ function renderContainerChildren(raw: string, children: Token[], basePath: strin
|
|
|
135
171
|
}
|
|
136
172
|
|
|
137
173
|
// Recursively render a token back to markdown, rewriting image refs
|
|
138
|
-
// in-place. Code / codespan
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
174
|
+
// in-place. Code / codespan tokens are emitted verbatim so image-ref
|
|
175
|
+
// syntax inside them stays literal. HTML tokens get a separate pass
|
|
176
|
+
// (`rewriteImgSrcAttrsInHtml`) so raw `<img>` tags route through the
|
|
177
|
+
// same basePath + shouldSkip pipeline as the markdown image syntax.
|
|
178
|
+
// Token-tree recursion uses the lexer's structural knowledge and never
|
|
179
|
+
// crosses a skip boundary — unlike the earlier `indexOf` splice which
|
|
180
|
+
// could rewrite a code-block literal when the same ref appeared in
|
|
181
|
+
// real markdown.
|
|
143
182
|
function renderToken(token: Token, basePath: string, out: string[]): void {
|
|
144
183
|
if (isSkippable(token)) {
|
|
145
184
|
out.push(token.raw);
|
|
@@ -150,6 +189,16 @@ function renderToken(token: Token, basePath: string, out: string[]): void {
|
|
|
150
189
|
out.push(replacement ?? token.raw);
|
|
151
190
|
return;
|
|
152
191
|
}
|
|
192
|
+
if (token.type === "html") {
|
|
193
|
+
// Block / inline HTML — rewrite raw <img> tags inside before
|
|
194
|
+
// emitting. Markdown image syntax () is handled by the
|
|
195
|
+
// image-token branch above; this branch covers the HTML-fallback
|
|
196
|
+
// path (#1011 Stage A). Fall back to verbatim raw if `raw` is
|
|
197
|
+
// unexpectedly missing — defensive against future marked changes.
|
|
198
|
+
const raw = (token as { raw?: string }).raw ?? "";
|
|
199
|
+
out.push(rewriteImgSrcAttrsInHtml(raw, basePath));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
153
202
|
const raw = (token as { raw?: string }).raw ?? "";
|
|
154
203
|
const children = getContainerChildren(token);
|
|
155
204
|
if (children && renderContainerChildren(raw, children, basePath, out)) {
|
|
@@ -167,14 +216,18 @@ function renderToken(token: Token, basePath: string, out: string[]): void {
|
|
|
167
216
|
* file (e.g. `"wiki/pages"` for `wiki/pages/foo.md`). Omit or pass
|
|
168
217
|
* `""` when resolving refs against the workspace root.
|
|
169
218
|
*
|
|
219
|
+
* Also rewrites the `src` attribute of raw `<img>` tags inside HTML
|
|
220
|
+
* blocks / inline HTML so a page mixing both syntaxes resolves the
|
|
221
|
+
* same way. Markdown image syntax inside code blocks / inline code
|
|
222
|
+
* spans is left alone.
|
|
223
|
+
*
|
|
170
224
|
* Absolute URLs, data URIs, and existing API paths pass through
|
|
171
225
|
* untouched. Refs that would escape the workspace root (more `..`
|
|
172
226
|
* than `basePath` depth) also pass through untouched — they would
|
|
173
227
|
* 404 regardless, and passing through lets the user see the broken
|
|
174
|
-
* ref instead of silently re-pointing it.
|
|
175
|
-
* code blocks / inline code spans is left alone.
|
|
228
|
+
* ref instead of silently re-pointing it.
|
|
176
229
|
*/
|
|
177
|
-
export function rewriteMarkdownImageRefs(markdown: string, basePath
|
|
230
|
+
export function rewriteMarkdownImageRefs(markdown: string, basePath = ""): string {
|
|
178
231
|
const tokens = marked.lexer(markdown);
|
|
179
232
|
const parts: string[] = [];
|
|
180
233
|
for (const token of tokens) renderToken(token, basePath, parts);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Canonical YAML-frontmatter parser / serializer / merger for the
|
|
2
|
+
// `---\nkey: value\n---\nbody` markdown convention. Used on the Vue
|
|
3
|
+
// side; the server side has a mirror at
|
|
4
|
+
// `server/utils/markdown/frontmatter.ts` (#895 PR B). The two share
|
|
5
|
+
// the same shape but live in separate files because the build
|
|
6
|
+
// targets are different (browser vs Node) and a shared package is
|
|
7
|
+
// overkill for ~10 lines of glue.
|
|
8
|
+
//
|
|
9
|
+
// Implementation uses `js-yaml` so we get full YAML coverage (block
|
|
10
|
+
// lists, multi-line strings, escaping) instead of the regex
|
|
11
|
+
// approximation in the legacy `src/utils/format/frontmatter.ts`.
|
|
12
|
+
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
export interface ParsedMarkdown {
|
|
16
|
+
/** Parsed YAML object. Empty `{}` when the document has no
|
|
17
|
+
* frontmatter or the YAML failed to parse. Insertion order
|
|
18
|
+
* matches the source — `Object.entries(meta)` is the right way
|
|
19
|
+
* to iterate for an ordered properties panel. */
|
|
20
|
+
meta: Record<string, unknown>;
|
|
21
|
+
/** Body after stripping the frontmatter envelope. Trailing
|
|
22
|
+
* newline at the end of the closing `---` line is consumed; a
|
|
23
|
+
* no-frontmatter document returns the raw input verbatim. */
|
|
24
|
+
body: string;
|
|
25
|
+
/** True iff a well-formed `---\n...\n---\n` envelope was
|
|
26
|
+
* detected and parsed. False for documents without an envelope
|
|
27
|
+
* or where the envelope is malformed (in which case the body
|
|
28
|
+
* is returned verbatim and `meta` is `{}`). */
|
|
29
|
+
hasHeader: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FRONTMATTER_OPEN = /^---\r?\n/;
|
|
33
|
+
// `(?:^|\r?\n)` lets the closing fence sit at the very start of
|
|
34
|
+
// `afterOpen` — needed for the empty-envelope case `---\n---\n`
|
|
35
|
+
// where the closing `---` is the first thing after the open is
|
|
36
|
+
// stripped. Without the alternation the regex required a preceding
|
|
37
|
+
// newline and silently treated empty headers as malformed.
|
|
38
|
+
const FRONTMATTER_CLOSE = /(?:^|\r?\n)---\s*(?:\r?\n|$)/;
|
|
39
|
+
|
|
40
|
+
/** Parse a markdown document, splitting frontmatter from body.
|
|
41
|
+
* Always returns an object — never throws. Malformed YAML inside
|
|
42
|
+
* a well-formed envelope falls back to `{ meta: {}, hasHeader: false }`
|
|
43
|
+
* so a typo in the header doesn't break rendering. */
|
|
44
|
+
export function parseFrontmatter(raw: string): ParsedMarkdown {
|
|
45
|
+
if (!FRONTMATTER_OPEN.test(raw)) {
|
|
46
|
+
return { meta: {}, body: raw, hasHeader: false };
|
|
47
|
+
}
|
|
48
|
+
const afterOpen = raw.replace(FRONTMATTER_OPEN, "");
|
|
49
|
+
const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);
|
|
50
|
+
if (!closeMatch || closeMatch.index === undefined) {
|
|
51
|
+
return { meta: {}, body: raw, hasHeader: false };
|
|
52
|
+
}
|
|
53
|
+
const yamlText = afterOpen.slice(0, closeMatch.index);
|
|
54
|
+
const body = afterOpen.slice(closeMatch.index + closeMatch[0].length);
|
|
55
|
+
const meta = safeYamlLoad(yamlText);
|
|
56
|
+
if (meta === null) {
|
|
57
|
+
return { meta: {}, body: raw, hasHeader: false };
|
|
58
|
+
}
|
|
59
|
+
return { meta, body, hasHeader: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Serialize a meta object + body back into the canonical
|
|
63
|
+
* `---\n...\n---\n\nbody` shape. An empty `meta` returns the body
|
|
64
|
+
* alone (no envelope) — the lazy-on-write contract: don't add
|
|
65
|
+
* ceremony to documents that don't have anything to record.
|
|
66
|
+
*
|
|
67
|
+
* Round-trip semantics: VALUE-preserving, NOT byte-preserving.
|
|
68
|
+
* `js-yaml` adds quotes to ambiguous scalars (`'1.20'`, `'true'`)
|
|
69
|
+
* so they parse back as the same string under FAILSAFE_SCHEMA.
|
|
70
|
+
* Source-text formatting (unquoted vs quoted) may change on save
|
|
71
|
+
* but the parsed value is stable across rounds (codex review
|
|
72
|
+
* iter-2 #902). */
|
|
73
|
+
export function serializeWithFrontmatter(meta: Record<string, unknown>, body: string): string {
|
|
74
|
+
if (Object.keys(meta).length === 0) return body;
|
|
75
|
+
// `lineWidth: -1` disables auto-wrap so long URLs / titles stay on
|
|
76
|
+
// one line. `noRefs: true` avoids YAML anchor syntax (`&id001`)
|
|
77
|
+
// which is technically valid but visually noisy in plain-text
|
|
78
|
+
// markdown. js-yaml's default already trims a trailing newline.
|
|
79
|
+
const yamlText = yaml.dump(meta, { lineWidth: -1, noRefs: true }).trimEnd();
|
|
80
|
+
return `---\n${yamlText}\n---\n\n${body}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Merge a patch into an existing meta object. Unknown keys in
|
|
84
|
+
* `existing` are preserved verbatim; keys present in `patch`
|
|
85
|
+
* overwrite. A `null` or `undefined` patch value DELETES the key
|
|
86
|
+
* (pattern borrowed from REST PATCH semantics) — callers that
|
|
87
|
+
* want "leave alone" should omit the key entirely. */
|
|
88
|
+
export function mergeFrontmatter(existing: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
|
89
|
+
const out: Record<string, unknown> = { ...existing };
|
|
90
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
91
|
+
if (value === null || value === undefined) {
|
|
92
|
+
Reflect.deleteProperty(out, key);
|
|
93
|
+
} else {
|
|
94
|
+
out[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function safeYamlLoad(text: string): Record<string, unknown> | null {
|
|
101
|
+
try {
|
|
102
|
+
// `FAILSAFE_SCHEMA` keeps every scalar as a string and skips
|
|
103
|
+
// type coercion. Two motivating cases:
|
|
104
|
+
//
|
|
105
|
+
// - YAML 1.1 dates: `created: 2026-04-27` would become a
|
|
106
|
+
// `Date` object under DEFAULT_SCHEMA, breaking round-trip.
|
|
107
|
+
// - Numeric-looking strings: `version: 1.20` → number 1.2
|
|
108
|
+
// under JSON_SCHEMA, dropping the trailing zero on save
|
|
109
|
+
// (codex review iter-1 #902).
|
|
110
|
+
//
|
|
111
|
+
// For our domain — title / created / updated / tags / editor —
|
|
112
|
+
// everything that should be a string IS one, and the rare
|
|
113
|
+
// caller that wants a number can coerce explicitly. Mappings
|
|
114
|
+
// and sequences still parse normally (FAILSAFE keeps those).
|
|
115
|
+
const loaded = yaml.load(text, { schema: yaml.FAILSAFE_SCHEMA });
|
|
116
|
+
// `yaml.load` returns `undefined` for empty input, a primitive
|
|
117
|
+
// for scalar-only YAML, or an object for the normal case. Only
|
|
118
|
+
// accept plain objects — anything else is a malformed header.
|
|
119
|
+
if (loaded === null || loaded === undefined) return {};
|
|
120
|
+
if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
|
|
121
|
+
return loaded as Record<string, unknown>;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
// so they need to be counted (and writable) by the index walker.
|
|
29
29
|
//
|
|
30
30
|
// Captures: prefix (indent + any `>` chains), bullet, separator, mark.
|
|
31
|
+
// `\s*` and `>\s*` operate on disjoint character classes from the
|
|
32
|
+
// surrounding bullet / separator / mark, so the nested quantifiers
|
|
33
|
+
// can't overlap to produce ReDoS — each pass is linear in line length.
|
|
34
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- markdown task-line parser, bounded captures with hard delimiters
|
|
31
35
|
const TASK_LINE = /^(\s*(?:>\s*)*)([-*+]|\d+[.)])(\s+)\[([ xX])\]/;
|
|
32
36
|
|
|
33
37
|
// Fenced code block opener/closer. CommonMark allows fences to be
|
|
@@ -43,6 +47,7 @@ const TASK_LINE = /^(\s*(?:>\s*)*)([-*+]|\d+[.)])(\s+)\[([ xX])\]/;
|
|
|
43
47
|
// be miscounted as a task — making the View's count-cross-check
|
|
44
48
|
// refuse all toggles in the whole document.
|
|
45
49
|
const FENCE_LINE = /^( {0,3})(`{3,}|~{3,})/;
|
|
50
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- bounded blockquote-prefix parser; `\s*` / `>\s?` / outer `+` operate on disjoint character classes (no overlap)
|
|
46
51
|
const BLOCKQUOTE_PREFIX = /^(\s*(?:>\s?)+)/;
|
|
47
52
|
|
|
48
53
|
// Mutable state for the line walker. Pulled out so the main toggle
|
|
@@ -66,7 +71,7 @@ function stepFence(line: string, state: FenceState): boolean {
|
|
|
66
71
|
const content = quoteMatch ? line.slice(quoteMatch[0].length) : line;
|
|
67
72
|
const fenceMatch = content.match(FENCE_LINE);
|
|
68
73
|
if (fenceMatch) {
|
|
69
|
-
const marker = fenceMatch
|
|
74
|
+
const [, , marker] = fenceMatch;
|
|
70
75
|
if (!state.inFence) {
|
|
71
76
|
// Openers may carry an info string after the marker
|
|
72
77
|
// (e.g. "```ts"). We don't need to keep it — just enter
|
|
@@ -103,7 +108,7 @@ function stepFence(line: string, state: FenceState): boolean {
|
|
|
103
108
|
function flipMark(line: string, match: RegExpMatchArray): string {
|
|
104
109
|
const [whole, prefix, bullet, sep, mark] = match;
|
|
105
110
|
const flipped = mark === " " ? "x" : " ";
|
|
106
|
-
return `${prefix}${bullet}${sep}[${flipped}]
|
|
111
|
+
return `${prefix}${bullet}${sep}[${flipped}]${line.slice(whole.length)}`;
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
/** Find the source-line index of every task-list item, in document
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Browser-side plugin runtime construction (#1110). The host's runtime
|
|
2
|
+
// plugin loader provides one of these per plugin via Vue's
|
|
3
|
+
// `provide(PLUGIN_RUNTIME_KEY, ...)`; the plugin's components fetch
|
|
4
|
+
// it via `useRuntime()` from `gui-chat-protocol/vue`.
|
|
5
|
+
//
|
|
6
|
+
// Every helper closes over `pkgName` so the plugin's pubsub channel
|
|
7
|
+
// and notify call cannot leak into another plugin's namespace.
|
|
8
|
+
|
|
9
|
+
import { computed, type Ref } from "vue";
|
|
10
|
+
import { useI18n } from "vue-i18n";
|
|
11
|
+
import type { BrowserPluginRuntime } from "gui-chat-protocol/vue";
|
|
12
|
+
import { usePubSub } from "../../composables/usePubSub";
|
|
13
|
+
import { apiPost } from "../api";
|
|
14
|
+
import { API_ROUTES } from "../../config/apiRoutes";
|
|
15
|
+
|
|
16
|
+
/** Build the channel name for a plugin's event. Must stay in lockstep
|
|
17
|
+
* with `server/plugins/runtime.ts:pluginChannelName`. */
|
|
18
|
+
export function pluginChannelName(pkgName: string, eventName: string): string {
|
|
19
|
+
return `plugin:${pkgName}:${eventName}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeScopedPubSub(pkgName: string): BrowserPluginRuntime["pubsub"] {
|
|
23
|
+
const { subscribe } = usePubSub();
|
|
24
|
+
return {
|
|
25
|
+
subscribe(eventName, handler) {
|
|
26
|
+
// The host pubsub fans payloads as `unknown`; the plugin
|
|
27
|
+
// declares the expected shape via the generic at the call
|
|
28
|
+
// site. Validation is the plugin's responsibility (Zod or
|
|
29
|
+
// hand-written guard).
|
|
30
|
+
return subscribe(pluginChannelName(pkgName, eventName), handler as (data: unknown) => void);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeScopedLogger(pkgName: string): BrowserPluginRuntime["log"] {
|
|
36
|
+
// Frontend logger maps to `console.*` in v1. The host's central
|
|
37
|
+
// logger lives server-side; routing browser logs there is a future
|
|
38
|
+
// enhancement that doesn't change this surface.
|
|
39
|
+
const tag = `[plugin/${pkgName}]`;
|
|
40
|
+
return {
|
|
41
|
+
debug: (msg, data) => console.debug(tag, msg, data),
|
|
42
|
+
info: (msg, data) => console.info(tag, msg, data),
|
|
43
|
+
warn: (msg, data) => console.warn(tag, msg, data),
|
|
44
|
+
error: (msg, data) => console.error(tag, msg, data),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Allowlisted URL schemes for `runtime.openUrl`. The two http schemes
|
|
49
|
+
* cover the legitimate "open this external page" use case; everything
|
|
50
|
+
* else (`javascript:`, `data:`, `vbscript:`, `file:`, custom schemes)
|
|
51
|
+
* is rejected. The `noopener,noreferrer` flags on `window.open`
|
|
52
|
+
* prevent the opened tab from snooping the opener but do NOT stop
|
|
53
|
+
* `javascript:` execution — that's why scheme filtering is the actual
|
|
54
|
+
* XSS guard. CodeRabbit review caught this on PR #1124. */
|
|
55
|
+
const OPEN_URL_ALLOWED_SCHEMES: ReadonlySet<string> = new Set(["http:", "https:"]);
|
|
56
|
+
|
|
57
|
+
function makeOpenUrl(pkgName: string): BrowserPluginRuntime["openUrl"] {
|
|
58
|
+
return (url: string) => {
|
|
59
|
+
let parsed: URL;
|
|
60
|
+
try {
|
|
61
|
+
parsed = new URL(url);
|
|
62
|
+
} catch {
|
|
63
|
+
console.warn(`[plugin/${pkgName}] openUrl rejected unparseable URL`, { url });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!OPEN_URL_ALLOWED_SCHEMES.has(parsed.protocol)) {
|
|
67
|
+
console.warn(`[plugin/${pkgName}] openUrl rejected non-http(s) scheme`, { scheme: parsed.protocol });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// `noopener` prevents the opened tab from accessing `window.opener`
|
|
71
|
+
// and snooping; `noreferrer` strips the Referer header so the
|
|
72
|
+
// destination can't see what page sent the user. Forced at the
|
|
73
|
+
// platform level so individual plugin links can't drop them.
|
|
74
|
+
const opened = window.open(url, "_blank", "noopener,noreferrer");
|
|
75
|
+
if (!opened) {
|
|
76
|
+
// Popup blocker engaged.
|
|
77
|
+
console.warn(`[plugin/${pkgName}] window.open returned null`, { url });
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeDispatch(pkgName: string): BrowserPluginRuntime["dispatch"] {
|
|
83
|
+
// Substitute `:pkg` in the contracted dispatch route. encodeURIComponent
|
|
84
|
+
// collapses scoped names (`@org/pkg`) into one URL path segment;
|
|
85
|
+
// the parameter pattern `:pkg` matches any segment.
|
|
86
|
+
const url = API_ROUTES.plugins.runtimeDispatch.replace(":pkg", encodeURIComponent(pkgName));
|
|
87
|
+
return async <T = unknown>(args: object): Promise<T> => {
|
|
88
|
+
const result = await apiPost<T>(url, args);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
throw new Error(`plugin/${pkgName} dispatch failed (${result.status}): ${result.error}`);
|
|
91
|
+
}
|
|
92
|
+
return result.data;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MakeBrowserPluginRuntimeDeps {
|
|
97
|
+
/** npm package name. Used both as the namespace prefix for
|
|
98
|
+
* pubsub channels and as the log prefix. */
|
|
99
|
+
pkgName: string;
|
|
100
|
+
/** Optional URL map exposed via `runtime.endpoints` for multi-URL
|
|
101
|
+
* built-in plugins. Runtime-loaded plugins (the common
|
|
102
|
+
* single-dispatch shape) leave this undefined. Built-in plugins
|
|
103
|
+
* (#1141) hand `{ method, url }` records; host-shared scopes hand
|
|
104
|
+
* plain string URLs — kept opaque here, narrowed at the consumer
|
|
105
|
+
* via `pluginEndpoints<E>(scope)`. See `BrowserPluginRuntime.endpoints`
|
|
106
|
+
* in `gui-chat-protocol@>=0.3.1` for the contract. */
|
|
107
|
+
endpoints?: Readonly<Record<string, unknown>>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function makeBrowserPluginRuntime(deps: MakeBrowserPluginRuntimeDeps): BrowserPluginRuntime {
|
|
111
|
+
const { pkgName, endpoints } = deps;
|
|
112
|
+
// `useI18n()` exposes `locale` as `WritableComputedRef<Locales>`.
|
|
113
|
+
// Wrapping in a fresh `computed` widens it to `Ref<string>` for
|
|
114
|
+
// plugin authors (so they don't need to import the host's locale
|
|
115
|
+
// union) while preserving reactivity.
|
|
116
|
+
const { locale: hostLocale } = useI18n();
|
|
117
|
+
const locale = computed(() => String(hostLocale.value)) as Ref<string>;
|
|
118
|
+
return {
|
|
119
|
+
pubsub: makeScopedPubSub(pkgName),
|
|
120
|
+
locale,
|
|
121
|
+
log: makeScopedLogger(pkgName),
|
|
122
|
+
openUrl: makeOpenUrl(pkgName),
|
|
123
|
+
dispatch: makeDispatch(pkgName),
|
|
124
|
+
// `BrowserPluginRuntime.endpoints` is now typed as the runtime's
|
|
125
|
+
// `E` type parameter (gui-chat-protocol@>=0.3.2, default
|
|
126
|
+
// `Readonly<Record<string, unknown>>`). Plugin authors pin the
|
|
127
|
+
// shape via `useRuntime<TheirShape>()` and read `runtime.endpoints!`
|
|
128
|
+
// without a cast. No coercion needed at this construction site —
|
|
129
|
+
// the host populates the field opaquely; each consumer narrows.
|
|
130
|
+
endpoints,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -11,6 +11,43 @@
|
|
|
11
11
|
import { isUserTextResponse } from "../tools/result";
|
|
12
12
|
import type { SessionSummary, ActiveSession } from "../../types/session";
|
|
13
13
|
|
|
14
|
+
// Server-side fields the sidebar inherits when present. `summary` /
|
|
15
|
+
// `keywords` are AI-generated; `origin` distinguishes scheduler /
|
|
16
|
+
// skill / bridge / human sessions; `isBookmarked` / `hasUnread` /
|
|
17
|
+
// `statusMessage` are server-tracked flags. None of these have a
|
|
18
|
+
// local fallback — copy them only when the server has actually set
|
|
19
|
+
// them, otherwise they'd surface as explicit `undefined` in shallow
|
|
20
|
+
// copies downstream.
|
|
21
|
+
const SERVER_OVERRIDE_KEYS = ["summary", "keywords", "origin", "isBookmarked", "hasUnread", "statusMessage"] as const;
|
|
22
|
+
|
|
23
|
+
export function pickServerOverrides(serverEntry: SessionSummary | undefined): Partial<SessionSummary> {
|
|
24
|
+
if (!serverEntry) return {};
|
|
25
|
+
const overrides: Partial<SessionSummary> = {};
|
|
26
|
+
for (const key of SERVER_OVERRIDE_KEYS) {
|
|
27
|
+
const value = serverEntry[key];
|
|
28
|
+
if (value !== undefined) {
|
|
29
|
+
Object.assign(overrides, { [key]: value });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return overrides;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fold every in-memory signal into isRunning so the sidebar spinner
|
|
36
|
+
// reacts as fast as the fastest source:
|
|
37
|
+
// - serverEntry.isRunning: authoritative but arrives on a /api/sessions
|
|
38
|
+
// refetch
|
|
39
|
+
// - live.isRunning: mirrored from the server via refreshSessionStates;
|
|
40
|
+
// may be ahead during the refetch window, and covers live-only
|
|
41
|
+
// sessions with no serverEntry yet
|
|
42
|
+
// - live.pendingGenerations: updates on the socket round-trip of a
|
|
43
|
+
// generationStarted event, before any REST refetch
|
|
44
|
+
// OR them so any one is enough. `live.isRunning` is always defined on
|
|
45
|
+
// an ActiveSession, so the summary always carries a boolean here.
|
|
46
|
+
export function computeLiveIsRunning(serverEntry: SessionSummary | undefined, live: Pick<ActiveSession, "isRunning" | "pendingGenerations">): boolean {
|
|
47
|
+
const pendingCount = Object.keys(live.pendingGenerations ?? {}).length;
|
|
48
|
+
return Boolean(serverEntry?.isRunning) || live.isRunning || pendingCount > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
// Build the summary shape the sidebar expects for a single live
|
|
15
52
|
// session. Server-side data (AI-generated title, summary,
|
|
16
53
|
// keywords) takes precedence over the local first-user-message
|
|
@@ -19,48 +56,14 @@ import type { SessionSummary, ActiveSession } from "../../types/session";
|
|
|
19
56
|
function buildLiveSummary(live: ActiveSession, serverEntry: SessionSummary | undefined): SessionSummary {
|
|
20
57
|
const firstUserMsg = live.toolResults.find(isUserTextResponse);
|
|
21
58
|
const preview = serverEntry?.preview || (firstUserMsg?.message ?? "");
|
|
22
|
-
|
|
59
|
+
return {
|
|
23
60
|
id: live.id,
|
|
24
61
|
roleId: live.roleId,
|
|
25
62
|
startedAt: live.startedAt,
|
|
26
63
|
updatedAt: live.updatedAt,
|
|
27
64
|
preview,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// reacts as fast as the fastest source:
|
|
31
|
-
// - serverEntry.isRunning: authoritative but arrives on a /api/sessions
|
|
32
|
-
// refetch
|
|
33
|
-
// - live.isRunning: mirrored from the server via refreshSessionStates;
|
|
34
|
-
// may be ahead during the refetch window, and covers live-only
|
|
35
|
-
// sessions with no serverEntry yet
|
|
36
|
-
// - live.pendingGenerations: updates on the socket round-trip of a
|
|
37
|
-
// generationStarted event, before any REST refetch
|
|
38
|
-
// OR them so any one is enough. `live.isRunning` is always defined on
|
|
39
|
-
// an ActiveSession, so the summary always carries a boolean here.
|
|
40
|
-
const pending = live.pendingGenerations ?? {};
|
|
41
|
-
const isRunning = !!serverEntry?.isRunning || live.isRunning || Object.keys(pending).length > 0;
|
|
42
|
-
// Carry summary / keywords ONLY if the server already has them.
|
|
43
|
-
// Object-spread with a conditional object keeps us from adding
|
|
44
|
-
// `undefined` values that would otherwise show up as explicit
|
|
45
|
-
// `summary: undefined` in a later shallow-copy.
|
|
46
|
-
return {
|
|
47
|
-
...base,
|
|
48
|
-
...(serverEntry?.summary !== undefined && { summary: serverEntry.summary }),
|
|
49
|
-
...(serverEntry?.keywords !== undefined && {
|
|
50
|
-
keywords: serverEntry.keywords,
|
|
51
|
-
}),
|
|
52
|
-
// `origin` is set once by the server when the session is created
|
|
53
|
-
// (scheduler / skill / bridge / human). A live session promoted
|
|
54
|
-
// from the server list must keep it — the tab bar renders the
|
|
55
|
-
// non-human glyph off this field.
|
|
56
|
-
...(serverEntry?.origin !== undefined && { origin: serverEntry.origin }),
|
|
57
|
-
isRunning,
|
|
58
|
-
...(serverEntry?.hasUnread !== undefined && {
|
|
59
|
-
hasUnread: serverEntry.hasUnread,
|
|
60
|
-
}),
|
|
61
|
-
...(serverEntry?.statusMessage !== undefined && {
|
|
62
|
-
statusMessage: serverEntry.statusMessage,
|
|
63
|
-
}),
|
|
65
|
+
...pickServerOverrides(serverEntry),
|
|
66
|
+
isRunning: computeLiveIsRunning(serverEntry, live),
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
|