@notis_ai/cli 0.2.18 → 0.2.20
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/README.md +542 -146
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_design_rules.json +320 -0
- package/config/portal-route-roots.json +45 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +42325 -18273
- package/dist/base-skills/notis-cli/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-cli/SKILL.md +51 -280
- package/dist/base-skills/notis-cli/references/app-delivery.md +17 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +67 -0
- package/dist/base-skills/notis-query/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-query/SKILL.md +35 -699
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +74 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/dist/base-skills/notis-spaces/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-spaces/SKILL.md +218 -0
- package/dist/base-skills/notis-spaces/references/architecture.md +116 -0
- package/dist/base-skills/notis-spaces/references/context.md +81 -0
- package/dist/base-skills/notis-spaces/references/design.md +141 -0
- package/dist/base-skills/notis-spaces/references/fixtures.md +29 -0
- package/dist/base-skills/notis-spaces/references/memory.md +58 -0
- package/dist/base-skills/notis-spaces/references/reading.md +73 -0
- package/dist/base-skills/notis-spaces/references/release.md +120 -0
- package/dist/base-skills/notis-spaces/references/reports.md +77 -0
- package/dist/base-skills/notis-spaces/references/sdk.md +273 -0
- package/dist/base-skills/notis-spaces/references/store.md +118 -0
- package/dist/base-skills/notis-spaces/references/troubleshooting.md +28 -0
- package/dist/base-skills/notis-spaces/references/views.md +229 -0
- package/{template/packages → dist}/sdk/package.json +9 -10
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +175 -0
- package/dist/sdk/src/components/Dialog.tsx +103 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +113 -0
- package/dist/sdk/src/components/DocumentPage.tsx +184 -0
- package/{template/packages → dist}/sdk/src/components/Markdown.tsx +9 -3
- package/{template/packages → dist}/sdk/src/components/MarkdownEditor.tsx +26 -5
- package/{template/packages → dist}/sdk/src/components/MultiSelectActionBar.tsx +95 -12
- package/{template/packages → dist}/sdk/src/components/MultiSelectCheckbox.tsx +4 -2
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/{template/packages → dist}/sdk/src/components/NotisSelectionBoundary.tsx +9 -5
- package/dist/sdk/src/components/RecordComponents.tsx +111 -0
- package/dist/sdk/src/components/Skeleton.tsx +38 -0
- package/dist/sdk/src/config.ts +31 -0
- package/{template/packages → dist}/sdk/src/documents.ts +21 -6
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/{template/packages → dist}/sdk/src/hooks/useBackend.ts +3 -2
- package/dist/sdk/src/hooks/useCloudComputer.ts +94 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/{template/packages → dist}/sdk/src/hooks/useCollectionInteractions.ts +157 -147
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +50 -0
- package/{template/packages → dist}/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/dist/sdk/src/hooks/useDocument.ts +51 -0
- package/{template/packages → dist}/sdk/src/hooks/useDocuments.ts +25 -59
- package/{template/packages → dist}/sdk/src/hooks/useHandover.ts +7 -7
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMarqueeSelection.ts +194 -0
- package/{template/packages → dist}/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +143 -0
- package/{template/packages → dist}/sdk/src/hooks/useNotis.ts +6 -1
- package/dist/sdk/src/hooks/useNotisNavigation.ts +70 -0
- package/dist/sdk/src/hooks/useQuery.ts +81 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +102 -0
- package/dist/sdk/src/hooks/useTopBarTitleLeaf.ts +75 -0
- package/{template/packages → dist}/sdk/src/hooks/useUpsertDocument.ts +1 -1
- package/dist/sdk/src/hooks/useViewParams.ts +195 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/{template/packages → dist}/sdk/src/index.ts +64 -1
- package/{template/packages → dist}/sdk/src/interactions/actions.ts +14 -1
- package/{template/packages → dist}/sdk/src/interactions/shortcuts.tsx +129 -23
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/{template/packages → dist}/sdk/src/interactions.ts +5 -1
- package/dist/sdk/src/nativeData.ts +39 -0
- package/{template/packages → dist}/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +292 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +675 -0
- package/dist/sdk/src/runtimeModules.ts +33 -0
- package/dist/sdk/src/space.ts +442 -0
- package/dist/sdk/src/standardViewParams.ts +36 -0
- package/{template/packages → dist}/sdk/src/styles.css +81 -16
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/ui/charts/RenderChartData.tsx +25 -0
- package/dist/sdk/src/ui/engine/core.ts +392 -0
- package/dist/sdk/src/ui/engine/dom.ts +183 -0
- package/dist/sdk/src/ui/engine/hooks.ts +195 -0
- package/dist/sdk/src/ui/engine/instance.ts +63 -0
- package/dist/sdk/src/ui/engine/keycapContext.ts +2 -0
- package/dist/sdk/src/ui/engine/registry.ts +262 -0
- package/dist/sdk/src/ui/engine/types.ts +62 -0
- package/dist/sdk/src/ui/icons.tsx +91 -0
- package/dist/sdk/src/ui/util.ts +83 -0
- package/dist/sdk/src/viewPeriod.ts +26 -0
- package/dist/sdk/src/vite.ts +92 -0
- package/dist/sdk/src/words/actions.ts +295 -0
- package/dist/sdk/src/words/clashes.ts +158 -0
- package/dist/sdk/src/words/format.ts +136 -0
- package/dist/sdk/src/words/index.ts +13 -0
- package/dist/sdk/src/words/keymap.ts +72 -0
- package/dist/sdk/src/words/keys.ts +297 -0
- package/dist/sdk/src/words/shell.ts +46 -0
- package/dist/sdk/src/words/types.ts +65 -0
- package/dist/skill-sync/index.js +832 -151
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +3941 -0
- package/dist/space-harness/host.js +1460 -0
- package/dist/space-sdk/dist/chunk-ZSN57ATH.js +5 -0
- package/dist/space-sdk/dist/index.js +15 -0
- package/dist/space-sdk/dist/ui.js +18 -0
- package/dist/space-sdk/package.json +33 -0
- package/dist/space-sdk/src/agentContext.d.ts +36 -0
- package/dist/space-sdk/src/components/Dialog.d.ts +20 -0
- package/dist/space-sdk/src/components/DocumentEditor.d.ts +46 -0
- package/dist/space-sdk/src/components/DocumentPage.d.ts +53 -0
- package/dist/space-sdk/src/components/Markdown.d.ts +18 -0
- package/dist/space-sdk/src/components/MarkdownEditor.d.ts +11 -0
- package/dist/space-sdk/src/components/MultiSelectActionBar.d.ts +31 -0
- package/dist/space-sdk/src/components/MultiSelectCheckbox.d.ts +14 -0
- package/dist/space-sdk/src/components/MultiSelectDragOverlay.d.ts +8 -0
- package/dist/space-sdk/src/components/NotisCommentBoundary.d.ts +36 -0
- package/dist/space-sdk/src/components/NotisSelectionBoundary.d.ts +16 -0
- package/dist/space-sdk/src/components/RecordComponents.d.ts +64 -0
- package/dist/space-sdk/src/components/ShortcutHints.d.ts +12 -0
- package/dist/space-sdk/src/components/Skeleton.d.ts +23 -0
- package/dist/space-sdk/src/config.ts +31 -0
- package/dist/space-sdk/src/documents.d.ts +59 -0
- package/dist/space-sdk/src/hooks/useAction.d.ts +8 -0
- package/dist/space-sdk/src/hooks/useActiveResource.d.ts +3 -0
- package/dist/space-sdk/src/hooks/useAgentContext.d.ts +7 -0
- package/dist/space-sdk/src/hooks/useBackend.d.ts +23 -0
- package/dist/space-sdk/src/hooks/useCloudComputer.d.ts +45 -0
- package/dist/space-sdk/src/hooks/useCloudComputerShell.d.ts +49 -0
- package/dist/space-sdk/src/hooks/useCollectionInteractions.d.ts +118 -0
- package/dist/space-sdk/src/hooks/useDatabaseSchema.d.ts +14 -0
- package/dist/space-sdk/src/hooks/useDatabaseSubscription.d.ts +26 -0
- package/dist/space-sdk/src/hooks/useDocument.d.ts +22 -0
- package/dist/space-sdk/src/hooks/useDocuments.d.ts +31 -0
- package/dist/space-sdk/src/hooks/useHandover.d.ts +43 -0
- package/dist/space-sdk/src/hooks/useLongPressSelection.d.ts +9 -0
- package/dist/space-sdk/src/hooks/useMarqueeSelection.d.ts +57 -0
- package/dist/space-sdk/src/hooks/useMultiSelect.d.ts +51 -0
- package/dist/space-sdk/src/hooks/useNativeDocumentBody.d.ts +24 -0
- package/dist/space-sdk/src/hooks/useNativeDocuments.d.ts +55 -0
- package/dist/space-sdk/src/hooks/useNotis.d.ts +25 -0
- package/dist/space-sdk/src/hooks/useNotisNavigation.d.ts +33 -0
- package/dist/space-sdk/src/hooks/useQuery.d.ts +22 -0
- package/dist/space-sdk/src/hooks/useTool.d.ts +20 -0
- package/dist/space-sdk/src/hooks/useToolQuery.d.ts +3 -0
- package/dist/space-sdk/src/hooks/useTools.d.ts +15 -0
- package/dist/space-sdk/src/hooks/useTopBarSearch.d.ts +48 -0
- package/dist/space-sdk/src/hooks/useTopBarTitleLeaf.d.ts +24 -0
- package/dist/space-sdk/src/hooks/useUpsertDocument.d.ts +27 -0
- package/dist/space-sdk/src/hooks/useViewParams.d.ts +76 -0
- package/dist/space-sdk/src/hooks/useViewerReads.d.ts +38 -0
- package/dist/space-sdk/src/index.d.ts +98 -0
- package/dist/space-sdk/src/interactions/actions.d.ts +33 -0
- package/dist/space-sdk/src/interactions/shortcuts.d.ts +42 -0
- package/dist/space-sdk/src/interactions/visibility.d.ts +2 -0
- package/dist/space-sdk/src/nativeData.d.ts +66 -0
- package/dist/space-sdk/src/provider.d.ts +17 -0
- package/dist/space-sdk/src/queryCache.d.ts +45 -0
- package/dist/space-sdk/src/queryErrors.ts +51 -0
- package/dist/space-sdk/src/runtime.d.ts +615 -0
- package/dist/space-sdk/src/runtimeModules.ts +33 -0
- package/dist/space-sdk/src/space.ts +442 -0
- package/dist/space-sdk/src/standardViewParams.d.ts +15 -0
- package/dist/space-sdk/src/tailwind-candidates.d.ts +3418 -0
- package/dist/space-sdk/src/tailwind.ts +66 -0
- package/dist/space-sdk/src/ui/actions/ActionToast.d.ts +15 -0
- package/dist/space-sdk/src/ui/actions/actionWords.d.ts +16 -0
- package/dist/space-sdk/src/ui/actions/index.d.ts +7 -0
- package/dist/space-sdk/src/ui/actions/useActions.d.ts +69 -0
- package/dist/space-sdk/src/ui/actions/useToast.d.ts +41 -0
- package/dist/space-sdk/src/ui/blocks/Tabs.d.ts +139 -0
- package/dist/space-sdk/src/ui/blocks/blocks.d.ts +217 -0
- package/dist/space-sdk/src/ui/blocks/index.d.ts +4 -0
- package/dist/space-sdk/src/ui/board/BoardView.d.ts +36 -0
- package/dist/space-sdk/src/ui/board/boardModel.d.ts +46 -0
- package/dist/space-sdk/src/ui/board/boardWords.d.ts +9 -0
- package/dist/space-sdk/src/ui/board/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/calendar/CalendarView.d.ts +49 -0
- package/dist/space-sdk/src/ui/calendar/DayPicker.d.ts +23 -0
- package/dist/space-sdk/src/ui/calendar/calendarMath.d.ts +81 -0
- package/dist/space-sdk/src/ui/charts/Chart.d.ts +203 -0
- package/dist/space-sdk/src/ui/charts/ChartTable.d.ts +37 -0
- package/dist/space-sdk/src/ui/charts/RenderChartData.d.ts +16 -0
- package/dist/space-sdk/src/ui/charts/StatRow.d.ts +59 -0
- package/dist/space-sdk/src/ui/charts/chartData.d.ts +84 -0
- package/dist/space-sdk/src/ui/comments/CommentPane.d.ts +58 -0
- package/dist/space-sdk/src/ui/comments/commentModel.d.ts +157 -0
- package/dist/space-sdk/src/ui/comments/commentWords.d.ts +60 -0
- package/dist/space-sdk/src/ui/comments/index.d.ts +4 -0
- package/dist/space-sdk/src/ui/comments/useComments.d.ts +146 -0
- package/dist/space-sdk/src/ui/engine/HelpSheet.d.ts +12 -0
- package/dist/space-sdk/src/ui/engine/KeyboardEngine.d.ts +27 -0
- package/dist/space-sdk/src/ui/engine/core.d.ts +73 -0
- package/dist/space-sdk/src/ui/engine/hooks.d.ts +64 -0
- package/dist/space-sdk/src/ui/engine/index.d.ts +14 -0
- package/dist/space-sdk/src/ui/engine/instance.d.ts +21 -0
- package/dist/space-sdk/src/ui/engine/keycapContext.d.ts +2 -0
- package/dist/space-sdk/src/ui/engine/registry.d.ts +77 -0
- package/dist/space-sdk/src/ui/engine/step.d.ts +11 -0
- package/dist/space-sdk/src/ui/engine/types.d.ts +54 -0
- package/dist/space-sdk/src/ui/fields/fieldWords.d.ts +56 -0
- package/dist/space-sdk/src/ui/fields/fields.d.ts +162 -0
- package/dist/space-sdk/src/ui/fields/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/fields/useFieldDraft.d.ts +22 -0
- package/dist/space-sdk/src/ui/gallery/AttachmentGallery.d.ts +53 -0
- package/dist/space-sdk/src/ui/gallery/ImageViewer.d.ts +27 -0
- package/dist/space-sdk/src/ui/gallery/galleryWords.d.ts +95 -0
- package/dist/space-sdk/src/ui/gallery/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/icons.d.ts +13 -0
- package/dist/space-sdk/src/ui/index.d.ts +54 -0
- package/dist/space-sdk/src/ui/keycap/Keycap.d.ts +22 -0
- package/dist/space-sdk/src/ui/keycap/index.d.ts +2 -0
- package/dist/space-sdk/src/ui/layout/Panels.d.ts +50 -0
- package/dist/space-sdk/src/ui/layout/chatPanelEdge.d.ts +77 -0
- package/dist/space-sdk/src/ui/layout/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/layout/panelMath.d.ts +91 -0
- package/dist/space-sdk/src/ui/list/ListReader.d.ts +380 -0
- package/dist/space-sdk/src/ui/list/Row.d.ts +145 -0
- package/dist/space-sdk/src/ui/list/SelectionBar.d.ts +65 -0
- package/dist/space-sdk/src/ui/list/index.d.ts +14 -0
- package/dist/space-sdk/src/ui/list/rowState.d.ts +22 -0
- package/dist/space-sdk/src/ui/list/useListKeys.d.ts +89 -0
- package/dist/space-sdk/src/ui/list/useReorder.d.ts +86 -0
- package/dist/space-sdk/src/ui/list/useSelection.d.ts +90 -0
- package/dist/space-sdk/src/ui/open/OpenAsMenu.d.ts +15 -0
- package/dist/space-sdk/src/ui/open/index.d.ts +6 -0
- package/dist/space-sdk/src/ui/open/openModeStore.d.ts +39 -0
- package/dist/space-sdk/src/ui/open/useOpenMode.d.ts +17 -0
- package/dist/space-sdk/src/ui/overlay/Menu.d.ts +88 -0
- package/dist/space-sdk/src/ui/overlay/Popover.d.ts +28 -0
- package/dist/space-sdk/src/ui/overlay/Tooltip.d.ts +19 -0
- package/dist/space-sdk/src/ui/overlay/index.d.ts +9 -0
- package/dist/space-sdk/src/ui/overlay/layerRoot.d.ts +24 -0
- package/dist/space-sdk/src/ui/overlay/position.d.ts +31 -0
- package/dist/space-sdk/src/ui/period/PeriodPicker.d.ts +30 -0
- package/dist/space-sdk/src/ui/period/dateFormat.d.ts +50 -0
- package/dist/space-sdk/src/ui/period/dateWords.d.ts +88 -0
- package/dist/space-sdk/src/ui/period/periods.d.ts +70 -0
- package/dist/space-sdk/src/ui/period/usePeriod.d.ts +23 -0
- package/dist/space-sdk/src/ui/present/index.d.ts +3 -0
- package/dist/space-sdk/src/ui/present/presentWords.d.ts +27 -0
- package/dist/space-sdk/src/ui/present/screens.d.ts +36 -0
- package/dist/space-sdk/src/ui/present/useAudienceScreen.d.ts +38 -0
- package/dist/space-sdk/src/ui/styles.d.ts +21 -0
- package/dist/space-sdk/src/ui/timeline/TimelineView.d.ts +44 -0
- package/dist/space-sdk/src/ui/timeline/timelineMath.d.ts +123 -0
- package/dist/space-sdk/src/viewPeriod.d.ts +8 -0
- package/dist/space-sdk/src/vite.ts +92 -0
- package/dist/space-sdk/src/words/actions.ts +295 -0
- package/dist/space-sdk/src/words/clashes.ts +158 -0
- package/dist/space-sdk/src/words/format.ts +136 -0
- package/dist/space-sdk/src/words/index.ts +13 -0
- package/dist/space-sdk/src/words/keymap.ts +72 -0
- package/dist/space-sdk/src/words/keys.ts +297 -0
- package/dist/space-sdk/src/words/shell.ts +46 -0
- package/dist/space-sdk/src/words/types.ts +65 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +2374 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +212 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +250 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/png.js +213 -0
- package/dist/view-renderer/src/pool.js +110 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +279 -0
- package/package.json +21 -11
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +63 -12
- package/skills/notis-onboarding/BRIEF.md +14 -9
- package/skills/notis-query/cli.md +1 -1
- package/skills/notis-spaces/cli.md +540 -0
- package/src/cli.js +8 -8
- package/src/command-specs/agents.js +121 -8
- package/src/command-specs/auth.js +3 -3
- package/src/command-specs/diagnostics.js +97 -20
- package/src/command-specs/documents.js +158 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +331 -79
- package/src/command-specs/helpers.js +50 -40
- package/src/command-specs/index.js +14 -5
- package/src/command-specs/meta.js +38 -9
- package/src/command-specs/onboarding.js +1 -1
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +314 -9
- package/src/command-specs/smoke.js +16 -4
- package/src/command-specs/spaces.js +783 -0
- package/src/command-specs/tools.js +118 -21
- package/src/command-specs/version-history.js +20 -0
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +178 -267
- package/src/runtime/agent-setup.js +172 -13
- package/src/runtime/app-boundary-validator.js +338 -2
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +750 -1570
- package/src/runtime/auth-recovery.js +17 -0
- package/src/runtime/base-skills.d.ts +11 -0
- package/src/runtime/base-skills.js +100 -3
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +5 -21
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +342 -31
- package/src/runtime/profiles.js +19 -112
- package/src/runtime/retired-sdk-files.js +24 -0
- package/src/runtime/retry.js +150 -0
- package/src/runtime/skill-sync/cloud-client.ts +33 -8
- package/src/runtime/skill-sync/index.ts +443 -106
- package/src/runtime/skill-sync/local-scanner.ts +248 -32
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +70 -0
- package/src/runtime/skill-sync/symlink-manager.ts +66 -16
- package/src/runtime/skill-sync/sync-plan.ts +34 -2
- package/src/runtime/skill-sync/types.ts +54 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +4 -4
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +107 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +68 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +133 -0
- package/src/runtime/space-source.js +290 -0
- package/src/runtime/space-test-server.js +354 -0
- package/src/runtime/space-verification.js +94 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +368 -0
- package/src/runtime/sync-skills.d.ts +5 -1
- package/src/runtime/sync-skills.js +11 -2
- package/src/runtime/tool-confirmation.js +138 -0
- package/src/runtime/transport.js +118 -24
- package/src/runtime/upsell.js +219 -0
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +114 -0
- package/src/space-harness-host.ts +122 -0
- package/template/.harness/index.html.tmpl +9 -352
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +2 -6
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +4 -36
- package/template/package-lock.json +1800 -1867
- package/template/package.json +16 -14
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/template/vite.config.ts +2 -2
- package/dist/base-skills/notis-apps/SKILL.md +0 -613
- package/skills/notis-apps/cli.md +0 -285
- package/src/command-specs/apps.js +0 -3011
- package/src/runtime/app-changelog.js +0 -79
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1140
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/app-registry-scaffolds.js +0 -367
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
- package/src/runtime/store-screenshot.js +0 -143
- package/template/CHANGELOG.md +0 -5
- package/template/app/page.tsx +0 -60
- package/template/components/ui/card.tsx +0 -56
- package/template/packages/sdk/src/components/DocumentEditor.tsx +0 -103
- package/template/packages/sdk/src/config.ts +0 -259
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/template/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +0 -52
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/template/packages/sdk/src/runtime.ts +0 -439
- package/template/packages/sdk/src/vite.ts +0 -56
- package/template/packages/sdk/tsconfig.json +0 -15
- /package/{template/packages → dist}/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
- /package/{template/packages → dist}/sdk/src/components/ShortcutHints.tsx +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useActiveResource.ts +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useTool.ts +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useTools.ts +0 -0
- /package/{template/packages → dist}/sdk/src/ui.ts +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createBrowserPool } from './pool.js';
|
|
3
|
+
import { expandScrollAreas, freezeAnimations, extractRenderedMarkdown, restrictBrowserTransports, freezeFrameViewportSizes, settleFrameImages } from './dom.js';
|
|
4
|
+
import { ViewRenderError, renderFailure } from './errors.js';
|
|
5
|
+
import { createPngStitcher } from './png.js';
|
|
6
|
+
|
|
7
|
+
export const RENDER_ORIGIN = 'http://notis-render.invalid';
|
|
8
|
+
const READ_OPERATIONS = new Set(['action', 'shown', 'viewer_read', 'document_body', 'record', 'html', 'report', 'resolve_links']);
|
|
9
|
+
const CSP = "default-src 'none'; script-src 'self' blob: data: 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob: data:; worker-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'";
|
|
10
|
+
const DEFAULT_HTML = '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/theme.css"><link rel="stylesheet" href="/host.css"></head><body><script src="/host.js"></script></body></html>';
|
|
11
|
+
|
|
12
|
+
function inputs({ outputs = ['markdown', 'screenshot'], width = 1440, timeoutMs = 45000 }) {
|
|
13
|
+
if (!Array.isArray(outputs) || !outputs.length || new Set(outputs).size !== outputs.length || outputs.some(value => !['markdown', 'screenshot'].includes(value))) throw new TypeError('Choose markdown and/or screenshot once.');
|
|
14
|
+
if (![390, 1440].includes(width)) throw new TypeError('Render at 390 or 1440 CSS pixels.');
|
|
15
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 90000) throw new TypeError('Use a bounded render timeout.');
|
|
16
|
+
return { outputs, width, timeoutMs };
|
|
17
|
+
}
|
|
18
|
+
function publicSnapshot(snapshot) {
|
|
19
|
+
// Explicit whitelist: never spread a ticket, broker object, endpoint or token
|
|
20
|
+
// into source-visible bootstrap data, even when the provider adds fields.
|
|
21
|
+
return { descriptor: snapshot.descriptor, source: snapshot.source, css: snapshot.css || '',
|
|
22
|
+
cacheKey: snapshot.cacheKey, markdownExport: snapshot.markdownExport || null,
|
|
23
|
+
url: snapshot.url, hostCss: snapshot.hostCss || '', params: snapshot.params || {}, locale: snapshot.locale || 'en' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PNG_LIMIT = 32 * 1024 * 1024;
|
|
27
|
+
const CAPTURE = { type: 'png', animations: 'disabled', caret: 'hide', scale: 'css' };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Chromium rasterizes everything a screenshot captures beyond the viewport, so a
|
|
31
|
+
* capture outside it costs memory in proportion to the page height, even when
|
|
32
|
+
* clipped to a slice: a tall page on a small instance is killed for memory.
|
|
33
|
+
* A page taller than one tile (the viewport, or sliceHeight when smaller) is
|
|
34
|
+
* scrolled one tile at a time and each tile is captured from the viewport only,
|
|
35
|
+
* so Chromium never paints more than one viewport, and the tiles are stitched
|
|
36
|
+
* into the full page. Every page is thus painted the way a browser window shows
|
|
37
|
+
* it. Expansion already set fixed and sticky elements static, so no element
|
|
38
|
+
* repeats from one tile to the next.
|
|
39
|
+
*/
|
|
40
|
+
async function capturePage(page, sliceHeight, maxBytes, maxHeight, maxPixels) {
|
|
41
|
+
// Playwright's own full-page size, so tiles cover exactly what one capture would.
|
|
42
|
+
const measure = () => page.evaluate(() => {
|
|
43
|
+
const body = document.body, root = document.documentElement;
|
|
44
|
+
return { width: Math.max(body.scrollWidth, root.scrollWidth, body.offsetWidth, root.offsetWidth, body.clientWidth, root.clientWidth),
|
|
45
|
+
height: Math.max(body.scrollHeight, root.scrollHeight, body.offsetHeight, root.offsetHeight, body.clientHeight, root.clientHeight),
|
|
46
|
+
viewport: { width: innerWidth, height: innerHeight } };
|
|
47
|
+
});
|
|
48
|
+
const size = await measure();
|
|
49
|
+
if (size.height > maxHeight || size.width * size.height > maxPixels)
|
|
50
|
+
throw new ViewRenderError('render_too_large', 'This view exceeds the full-page render limit; nothing was cropped.', 413);
|
|
51
|
+
const tile = Math.min(sliceHeight, size.viewport.height);
|
|
52
|
+
// A page within one tile fits the viewport, where Playwright captures it as is.
|
|
53
|
+
if (size.height <= tile) return page.screenshot({ ...CAPTURE, fullPage: true });
|
|
54
|
+
// Each tile is compressed into the output as it is captured, so no tile is held.
|
|
55
|
+
const stitcher = createPngStitcher({ maxBytes });
|
|
56
|
+
if (size.width !== size.viewport.width) {
|
|
57
|
+
// A page wider than the viewport has no viewport tile that holds a whole row:
|
|
58
|
+
// capture clipped slices of the full layout instead, at their higher cost.
|
|
59
|
+
for (let y = 0; y < size.height; y += sliceHeight) {
|
|
60
|
+
const height = Math.min(sliceHeight, size.height - y);
|
|
61
|
+
const slice = await page.screenshot({ ...CAPTURE, fullPage: true, clip: { x: 0, y, width: size.width, height } });
|
|
62
|
+
if (slice.readUInt32BE(16) !== size.width || slice.readUInt32BE(20) !== height) throw renderFailure('render_unstable');
|
|
63
|
+
await stitcher.add(slice);
|
|
64
|
+
}
|
|
65
|
+
return stitcher.finish();
|
|
66
|
+
}
|
|
67
|
+
for (const frame of page.frames()) await frame.evaluate(freezeAnimations);
|
|
68
|
+
const session = await page.context().newCDPSession(page);
|
|
69
|
+
try {
|
|
70
|
+
for (let y = 0; y < size.height; y += tile) {
|
|
71
|
+
const height = Math.min(tile, size.height - y);
|
|
72
|
+
// The last scroll stops at the page end; its tile is still inside the viewport.
|
|
73
|
+
const top = await page.evaluate(([y, height]) => {
|
|
74
|
+
window.scrollTo(0, y);
|
|
75
|
+
return new Promise(resolve => requestAnimationFrame(() => resolve(scrollY))).then(top => (top <= y && top + innerHeight >= y + height ? top : -1));
|
|
76
|
+
}, [y, height]);
|
|
77
|
+
if (top < 0) throw renderFailure('render_unstable');
|
|
78
|
+
const { data } = await session.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false,
|
|
79
|
+
clip: { x: 0, y, width: size.width, height, scale: 1 } });
|
|
80
|
+
const slice = Buffer.from(data, 'base64');
|
|
81
|
+
if (slice.readUInt32BE(16) !== size.width || slice.readUInt32BE(20) !== height) throw renderFailure('render_unstable');
|
|
82
|
+
await stitcher.add(slice);
|
|
83
|
+
}
|
|
84
|
+
} finally { await session.detach().catch(() => {}); }
|
|
85
|
+
// Scrolling must not have revealed more of the page than the tiles covered.
|
|
86
|
+
const after = await measure();
|
|
87
|
+
if (after.height !== size.height || after.width !== size.width) throw renderFailure('render_unstable');
|
|
88
|
+
return stitcher.finish();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function diagnosticFailure(error, stage, fallback = 'render_failed') {
|
|
92
|
+
const failure = error instanceof ViewRenderError
|
|
93
|
+
? new ViewRenderError(error.code, error.message, error.status) : renderFailure(fallback);
|
|
94
|
+
// Private in-process diagnostics only. Public JSON/error projections cannot
|
|
95
|
+
// enumerate a browser exception, which may contain source or request data.
|
|
96
|
+
Object.defineProperties(failure, { cause: { value: error }, stage: { value: stage } });
|
|
97
|
+
return failure;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shared by CLI and warm service. No broad credential is an argument to this library. */
|
|
101
|
+
export async function renderView({ broker, outputs, width, timeoutMs, signal, pool, host, maxHeight = 60000, maxPixels = 80000000, sliceHeight = 2048, maxPngBytes = PNG_LIMIT }) {
|
|
102
|
+
const config = inputs({ outputs, width, timeoutMs });
|
|
103
|
+
if (!Number.isInteger(sliceHeight) || sliceHeight < 256 || sliceHeight > 16384) throw new TypeError('Use a bounded screenshot slice height.');
|
|
104
|
+
if (!Number.isInteger(maxPngBytes) || maxPngBytes < 1 || maxPngBytes > PNG_LIMIT) throw new TypeError('Use a PNG limit within the render transport limit.');
|
|
105
|
+
const deadline = AbortSignal.timeout(config.timeoutMs);
|
|
106
|
+
const cancellation = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
107
|
+
// Fail authorization before opening a browser or exposing cached page data.
|
|
108
|
+
let snapshot;
|
|
109
|
+
try { snapshot = await broker.snapshot({ signal: cancellation }); }
|
|
110
|
+
catch (error) { throw diagnosticFailure(error, 'manifest', cancellation.aborted ? 'render_timeout' : 'render_failed'); }
|
|
111
|
+
if (!snapshot || typeof snapshot.url !== 'string' || !snapshot.view || !snapshot.descriptor || typeof snapshot.source !== 'string') throw renderFailure('render_manifest_invalid');
|
|
112
|
+
if (snapshot.render_options && (!Array.isArray(snapshot.render_options.outputs) || snapshot.render_options.width !== config.width
|
|
113
|
+
|| JSON.stringify([...snapshot.render_options.outputs].sort()) !== JSON.stringify([...config.outputs].sort()))) {
|
|
114
|
+
throw new ViewRenderError('render_options_changed', 'Use the outputs and width authorized for this render.', 409);
|
|
115
|
+
}
|
|
116
|
+
const hostSource = host?.script ?? await readFile(new URL('../dist/host.js', import.meta.url), 'utf8');
|
|
117
|
+
snapshot.hostCss = host ? host.css || '' : await readFile(new URL('../dist/host.css', import.meta.url), 'utf8');
|
|
118
|
+
snapshot.theme_css ||= host ? host.themeCss || '' : await readFile(new URL('../dist/theme.css', import.meta.url), 'utf8');
|
|
119
|
+
const ownPool = !pool;
|
|
120
|
+
pool ||= createBrowserPool();
|
|
121
|
+
const blocked = [];
|
|
122
|
+
let authorityLost = false, authorityFailure, transientFailure, assetRequests = 0, assetBytes = 0, readRequests = 0;
|
|
123
|
+
let stage = 'browser_context';
|
|
124
|
+
let assetOrigin = null;
|
|
125
|
+
try { const asset = new URL(snapshot.asset_origin); if (asset.protocol === 'https:' && asset.origin === snapshot.asset_origin) assetOrigin = asset.origin; } catch {}
|
|
126
|
+
const policy = assetOrigin ? CSP.replace("img-src 'self' data: blob:", "img-src 'self' data: blob: " + assetOrigin).replace("font-src 'self' data:", "font-src 'self' data: " + assetOrigin) : CSP;
|
|
127
|
+
try {
|
|
128
|
+
return await pool.use(async context => {
|
|
129
|
+
stage = 'browser_setup';
|
|
130
|
+
context.setDefaultTimeout(config.timeoutMs);
|
|
131
|
+
await context.addInitScript(restrictBrowserTransports);
|
|
132
|
+
// Offline fixtures freeze "now" so time-windowed reads match their recorded
|
|
133
|
+
// inputs. Timers keep running; live renders send no clock and use real time.
|
|
134
|
+
const clock = typeof snapshot.fixed_clock === 'string' ? Date.parse(snapshot.fixed_clock) : NaN;
|
|
135
|
+
if (Number.isFinite(clock)) await context.clock.setFixedTime(new Date(clock));
|
|
136
|
+
await context.routeWebSocket('**/*', socket => socket.close({ code: 1008, reason: 'Read-only rendering has no sockets.' }));
|
|
137
|
+
await context.route('**/*', async route => {
|
|
138
|
+
const request = route.request();
|
|
139
|
+
const url = new URL(request.url());
|
|
140
|
+
const respond = (status, data, contentType = 'application/json') => route.fulfill({ status, body: typeof data === 'string' ? data : JSON.stringify(data),
|
|
141
|
+
contentType, headers: { 'Cache-Control': 'no-store', 'Content-Security-Policy': policy, 'X-Content-Type-Options': 'nosniff' } });
|
|
142
|
+
let payload;
|
|
143
|
+
try {
|
|
144
|
+
if (url.origin !== RENDER_ORIGIN || url.search || url.hash) {
|
|
145
|
+
const kind = request.resourceType();
|
|
146
|
+
if (url.origin === assetOrigin && request.method() === 'GET' && ['image', 'font'].includes(kind)
|
|
147
|
+
&& !url.search && !url.hash && ++assetRequests <= 128) {
|
|
148
|
+
const asset = await broker.read({ operation: 'asset', url: url.href, kind }, { signal: cancellation });
|
|
149
|
+
const bytes = Buffer.from(asset.data_base64, 'base64'); assetBytes += bytes.length;
|
|
150
|
+
if (assetBytes > 32 * 1024 * 1024 || !/^(image|font)\//.test(asset.mime_type)) throw renderFailure('render_asset_limit');
|
|
151
|
+
return route.fulfill({ status: 200, body: bytes, contentType: asset.mime_type,
|
|
152
|
+
headers: { 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff' } });
|
|
153
|
+
}
|
|
154
|
+
if (blocked.length < 100) blocked.push({ code: 'render_egress_denied' });
|
|
155
|
+
return route.abort('blockedbyclient');
|
|
156
|
+
}
|
|
157
|
+
if (request.method() === 'GET') {
|
|
158
|
+
if (url.pathname === '/') return respond(200, host?.html || DEFAULT_HTML, 'text/html');
|
|
159
|
+
if (url.pathname === '/host.js') return respond(200, hostSource, 'text/javascript');
|
|
160
|
+
if (url.pathname === '/host.css') return respond(200, snapshot.hostCss || '', 'text/css');
|
|
161
|
+
if (url.pathname === '/theme.css') return respond(200, snapshot.theme_css || '', 'text/css');
|
|
162
|
+
if (url.pathname === '/__snapshot') return respond(200, publicSnapshot(snapshot));
|
|
163
|
+
return respond(404, { error: 'render_resource_unavailable' });
|
|
164
|
+
}
|
|
165
|
+
if (url.pathname !== '/__read' || request.method() !== 'POST' || (request.postData()?.length || 0) > 262144) return respond(403, { error: 'render_read_only' });
|
|
166
|
+
payload = request.postDataJSON();
|
|
167
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !READ_OPERATIONS.has(payload.operation)) {
|
|
168
|
+
if (blocked.length < 100) blocked.push({ code: 'render_read_only' });
|
|
169
|
+
return respond(403, { error: 'render_read_only', message: 'Rendering never changes records or runs write actions.' });
|
|
170
|
+
}
|
|
171
|
+
if (++readRequests > 128) throw new ViewRenderError('render_read_limit', 'This render exceeded its read limit.', 429);
|
|
172
|
+
// The server independently reconstructs and classifies every call.
|
|
173
|
+
const result = await broker.read(payload, { signal: cancellation });
|
|
174
|
+
return respond(200, { result });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const code = ['render_read_only', 'render_paid_background_denied'].includes(error?.code) ? error.code : 'render_read_unavailable';
|
|
177
|
+
if (['render_read_only', 'render_paid_background_denied'].includes(code)) {
|
|
178
|
+
if (blocked.length < 100) blocked.push({ code, ...(typeof payload?.action_id === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(payload.action_id) ? { action_id: payload.action_id } : {}) });
|
|
179
|
+
} else if (error?.status >= 500 || error?.status === 429) {
|
|
180
|
+
// Backend load is not an access change: fail the capture as retryable.
|
|
181
|
+
transientFailure ||= error;
|
|
182
|
+
} else { authorityLost = true; authorityFailure ||= error; }
|
|
183
|
+
await respond(error?.status === 401 || error?.status === 404 ? error.status : 403, { error: code, message: 'This read is unavailable in the current render.' }).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
const page = await context.newPage();
|
|
187
|
+
context.on('page', child => { if (child !== page) void child.close(); });
|
|
188
|
+
stage = 'host_navigation';
|
|
189
|
+
await page.goto(RENDER_ORIGIN, { waitUntil: 'domcontentloaded' });
|
|
190
|
+
stage = 'host_ready';
|
|
191
|
+
await page.waitForFunction(() => window.__notisRenderState?.mounted && window.__notisRenderState.pending === 0 && performance.now() - window.__notisRenderState.lastActivity > 100, undefined, { timeout: config.timeoutMs });
|
|
192
|
+
// A committed data response must settle React layout and browser fonts.
|
|
193
|
+
stage = 'fonts_layout';
|
|
194
|
+
await page.evaluate(async () => {
|
|
195
|
+
await Promise.race([document.fonts.ready, new Promise(resolve => setTimeout(resolve, 2000))]);
|
|
196
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
197
|
+
});
|
|
198
|
+
let lastHeight = -1, settled = false;
|
|
199
|
+
const frozenFrames = new WeakSet();
|
|
200
|
+
stage = 'layout_expansion';
|
|
201
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
202
|
+
for (const frame of [...page.frames()].reverse()) {
|
|
203
|
+
if (!await frame.evaluate(settleFrameImages)) throw renderFailure('render_asset_unavailable');
|
|
204
|
+
if (frame !== page.mainFrame() && !frozenFrames.has(frame)) {
|
|
205
|
+
await frame.evaluate(freezeFrameViewportSizes); frozenFrames.add(frame);
|
|
206
|
+
}
|
|
207
|
+
const size = await frame.evaluate(expandScrollAreas, { maxHeight });
|
|
208
|
+
if (size.tooLarge) throw new ViewRenderError('render_too_large', 'This view exceeds the full-page render limit; nothing was cropped.', 413);
|
|
209
|
+
if (frame !== page.mainFrame()) await (await frame.frameElement()).evaluate((node, height) => {
|
|
210
|
+
node.style.setProperty('height', `${height}px`, 'important'); node.style.setProperty('min-height', `${height}px`, 'important');
|
|
211
|
+
node.style.setProperty('max-height', 'none', 'important'); node.style.setProperty('flex-shrink', '0', 'important');
|
|
212
|
+
node.style.setProperty('flex-basis', 'auto', 'important');
|
|
213
|
+
}, size.height);
|
|
214
|
+
}
|
|
215
|
+
// Expansion can reveal lazy rows and trigger additional declared reads.
|
|
216
|
+
// Wait for their committed DOM before deciding that the full page fits.
|
|
217
|
+
await page.waitForFunction(() => window.__notisRenderState.pending === 0 && performance.now() - window.__notisRenderState.lastActivity > 100,
|
|
218
|
+
undefined, { timeout: config.timeoutMs });
|
|
219
|
+
const height = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body.scrollHeight));
|
|
220
|
+
if (height === lastHeight) { settled = true; break; }
|
|
221
|
+
lastHeight = height;
|
|
222
|
+
}
|
|
223
|
+
if (!settled) throw new ViewRenderError('render_unstable', 'This view did not settle into a full-page capture; nothing was cropped.', 409);
|
|
224
|
+
if (blocked.some(value => value.code === 'render_paid_background_denied')) throw new ViewRenderError('render_paid_background_denied', 'Background snapshots cannot run paid reads.', 403);
|
|
225
|
+
const dimensions = await page.evaluate(() => {
|
|
226
|
+
const body = document.body, root = document.documentElement;
|
|
227
|
+
return { width: Math.max(body.scrollWidth, root.scrollWidth, body.offsetWidth, root.offsetWidth, body.clientWidth, root.clientWidth),
|
|
228
|
+
height: Math.max(body.scrollHeight, root.scrollHeight, body.offsetHeight, root.offsetHeight, body.clientHeight, root.clientHeight) };
|
|
229
|
+
});
|
|
230
|
+
if (dimensions.height > maxHeight || dimensions.width * dimensions.height > maxPixels) throw new ViewRenderError('render_too_large', 'This view exceeds the full-page render limit; nothing was cropped.', 413);
|
|
231
|
+
let markdown, screenshot;
|
|
232
|
+
if (config.outputs.includes('markdown')) {
|
|
233
|
+
stage = 'markdown';
|
|
234
|
+
let override;
|
|
235
|
+
try { override = await page.evaluate(async () => {
|
|
236
|
+
if (typeof window.__notisRenderMarkdown === 'function') return await window.__notisRenderMarkdown();
|
|
237
|
+
return null;
|
|
238
|
+
}); } catch { throw renderFailure('render_markdown_invalid'); }
|
|
239
|
+
if (override !== null && typeof override !== 'string') throw renderFailure('render_markdown_invalid');
|
|
240
|
+
markdown = override ?? (await Promise.all(page.frames().map(frame => frame.evaluate(extractRenderedMarkdown, { baseUrl: snapshot.url })))).filter(Boolean).join('\n\n');
|
|
241
|
+
if (Buffer.byteLength(markdown, 'utf8') > 2000000) throw new ViewRenderError('render_too_large', 'The Markdown exceeds the render limit.', 413);
|
|
242
|
+
markdown = markdown.replace(/\r\n/g, '\n').trim() + '\n';
|
|
243
|
+
}
|
|
244
|
+
if (config.outputs.includes('screenshot')) {
|
|
245
|
+
stage = 'screenshot';
|
|
246
|
+
const png = await capturePage(page, sliceHeight, maxPngBytes, maxHeight, maxPixels);
|
|
247
|
+
if (png.readUInt32BE(20) > maxHeight || png.readUInt32BE(16) * png.readUInt32BE(20) > maxPixels)
|
|
248
|
+
throw new ViewRenderError('render_too_large', 'This view exceeds the full-page render limit; nothing was cropped.', 413);
|
|
249
|
+
if (png.length > maxPngBytes) throw new ViewRenderError('render_too_large', 'The PNG exceeds the render transport limit; nothing was cropped.', 413);
|
|
250
|
+
screenshot = { mime_type: 'image/png', viewport_width: config.width, data_base64: png.toString('base64'), width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
251
|
+
}
|
|
252
|
+
stage = 'read_completion';
|
|
253
|
+
if (blocked.some(value => value.code === 'render_paid_background_denied')) throw new ViewRenderError('render_paid_background_denied', 'Background snapshots cannot run paid reads.', 403);
|
|
254
|
+
for (const code of await page.evaluate(() => (window.__notisRenderState.blocked || []).filter(value => value === 'render_read_only').slice(0, 100))) {
|
|
255
|
+
if (blocked.length < 100) blocked.push({ code });
|
|
256
|
+
}
|
|
257
|
+
if (transientFailure && !authorityLost && !cancellation.aborted) {
|
|
258
|
+
const failure = new ViewRenderError('render_temporarily_unavailable', 'The view could not be rendered right now. Try again in a moment.', 503);
|
|
259
|
+
Object.defineProperty(failure, 'cause', { value: transientFailure });
|
|
260
|
+
throw failure;
|
|
261
|
+
}
|
|
262
|
+
if (authorityLost || cancellation.aborted || await page.evaluate(() => Boolean(window.__notisRenderState.failed))) {
|
|
263
|
+
const failure = renderFailure('render_access_changed');
|
|
264
|
+
if (authorityFailure) Object.defineProperty(failure, 'cause', { value: authorityFailure });
|
|
265
|
+
throw failure;
|
|
266
|
+
}
|
|
267
|
+
// Do not release Markdown or image bytes after revocation/source changes.
|
|
268
|
+
stage = 'authorize_finish';
|
|
269
|
+
const finalAuthority = await broker.finish({ signal: cancellation });
|
|
270
|
+
if (finalAuthority?.valid !== true) throw renderFailure('render_access_changed');
|
|
271
|
+
return { url: snapshot.url, view: snapshot.view, ...(snapshot.record_key ? { record_key: snapshot.record_key } : {}),
|
|
272
|
+
...(typeof finalAuthority.render_id === 'string' ? { render_id: finalAuthority.render_id } : {}),
|
|
273
|
+
params: snapshot.params || {}, ...(markdown !== undefined ? { markdown } : {}), ...(screenshot ? { screenshot } : {}),
|
|
274
|
+
rendered_at: new Date().toISOString(), ...(blocked.length ? { blocked_calls: blocked } : {}) };
|
|
275
|
+
}, { signal: cancellation, width: config.width, locale: snapshot.locale || 'en', timezone: snapshot.timezone || 'UTC' });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw diagnosticFailure(error, stage, cancellation.aborted ? 'render_timeout' : 'render_failed');
|
|
278
|
+
} finally { if (ownPool) await pool.close(); }
|
|
279
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@notis_ai/cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Agent-first Notis CLI for
|
|
3
|
+
"version": "0.2.20",
|
|
4
|
+
"description": "Agent-first Notis CLI for Spaces, Skills and generic tool execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"notis": "bin/notis.js"
|
|
@@ -22,19 +22,20 @@
|
|
|
22
22
|
"prepack": "npm run build",
|
|
23
23
|
"pretest": "npm run build",
|
|
24
24
|
"release:prepare": "npm run build && node ./scripts/prepare-publish.js --apply",
|
|
25
|
-
"cli:set-mode": "node ./scripts/set-cli-mode.js",
|
|
26
25
|
"smoke": "node ./scripts/smoke-package.js",
|
|
27
|
-
"test": "node --test --test-timeout=120000"
|
|
26
|
+
"test": "node --test --test-timeout=120000",
|
|
27
|
+
"preinstall": "node ./bin/check-runtime.js"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
30
|
-
"node": ">=
|
|
30
|
+
"node": ">=22.12.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"esbuild": "^0.28.2"
|
|
33
|
+
"@babel/parser": "^7.29.9",
|
|
34
|
+
"ajv": "^8.20.0",
|
|
35
|
+
"ajv-formats": "^3.0.1",
|
|
36
|
+
"commander": "^15.0.0",
|
|
37
|
+
"esbuild": "^0.28.2",
|
|
38
|
+
"playwright-core": "1.63.0"
|
|
38
39
|
},
|
|
39
40
|
"keywords": [
|
|
40
41
|
"notis",
|
|
@@ -45,5 +46,14 @@
|
|
|
45
46
|
"publishConfig": {
|
|
46
47
|
"access": "public"
|
|
47
48
|
},
|
|
48
|
-
"license": "MIT"
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"react": "19.3.0",
|
|
52
|
+
"react-dom": "19.3.0"
|
|
53
|
+
},
|
|
54
|
+
"notisRelease": {
|
|
55
|
+
"sourceSha": "2f38334adf5ebfc2aecbc3c7ae6f5ad7a14c6a0b",
|
|
56
|
+
"channel": "stable"
|
|
57
|
+
},
|
|
58
|
+
"gitHead": "2f38334adf5ebfc2aecbc3c7ae6f5ad7a14c6a0b"
|
|
49
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- notis-cli:instructions:start -->
|
|
2
2
|
## Notis CLI
|
|
3
3
|
|
|
4
|
-
Use the Notis CLI (`npx --package @notis_ai/cli@latest -- notis ...`) whenever the task involves the user's personal data or connected services: integrations (Gmail, Google Calendar, Notion, Slack, GitHub, ...), long-term memories, reminders, automations, native Notis databases and notes, public deep research
|
|
4
|
+
Use the Notis CLI (`npx --package @notis_ai/cli@latest -- notis ...`) whenever the task involves the user's personal data or connected services: integrations (Gmail, Google Calendar, Notion, Slack, GitHub, ...), long-term memories, reminders, automations, native Notis databases and notes, and public deep research. Prefer it over telling the user to operate another app manually, and never treat a task as blocked merely because a capability is absent from the current tool list—reach it through the CLI.
|
|
5
5
|
|
|
6
6
|
On a local machine, `notis login` authorizes a scoped, revocable OAuth profile in the browser. Notis Desktop may authorize that profile automatically, but the Desktop app does not need to remain running. Hosted Notis shells use `NOTIS_JWT`. Before acting on personal data, run `notis whoami` when the intended account or endpoint is not already clear.
|
|
7
7
|
|
|
@@ -9,31 +9,82 @@ Relevant Notis long-term memory may be injected into a turn inside `<notis_relev
|
|
|
9
9
|
|
|
10
10
|
### Tool access workflow
|
|
11
11
|
|
|
12
|
-
1. Discover tools with natural language: `notis tools search "<full description of what you need>"
|
|
12
|
+
1. Discover tools with natural language: `notis tools search "<full description of what you need>"` for anything not listed below; never guess tool names, identifiers, or argument shapes. `notis tools toolkits` lists connected namespaces.
|
|
13
13
|
2. Inspect the schema when unsure: `notis tools describe <TOOL>` or `notis tools exec <TOOL> --get-schema`.
|
|
14
14
|
3. Validate mutating calls first: `notis tools exec <TOOL> --dry-run --arguments '<json>'`.
|
|
15
15
|
4. Execute: `notis tools exec <TOOL> --arguments '<json>'`; batch independent calls with `notis tools exec-parallel '<json-array>'`.
|
|
16
16
|
5. If an integration is not connected, `notis tools link <toolkit>` prints a connection URL to give the user.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
For memory writes, reminders, automations, database writes, public deep research and Notis Spaces, discover the tools not listed below and follow the applicable installed Skill.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Run one discovery query per capability and reuse its tool names and schemas marked `hasFullSchema: true`; fetch a schema only when it is missing or incomplete. For a large answer, capture it once, keep stderr separate from stdout, and inspect the saved response locally instead of truncating it and repeating discovery. A returned session field is a tool argument, not an instruction to add CLI flags.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
Tool name shapes: `LOCAL_NOTIS_*` (native Notis), `LOCAL_NOTIS_DATABASE_*` (databases and notes), `CUSTOM_*` (the user's remote MCP servers), `LOCAL_MCP_*` (MCP servers on the user's computer), and integration tools such as `GMAIL_*`, `NOTION_*`, and `GOOGLECALENDAR_*`.
|
|
23
|
+
|
|
24
|
+
### Notis tools to call directly
|
|
25
|
+
|
|
26
|
+
Call these with `notis tools exec <TOOL>` (add `--get-schema` first when you need the arguments), without `notis tools search`. Search only for anything else.
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"name": "LOCAL_NOTIS_LOAD_CONVERSATION_HISTORY",
|
|
32
|
+
"description": "Read the current or specified owned topical conversation, including user replies, sent messages, attachments, saved voice transcripts with speaker roles and delivery state. Use LOCAL_NOTIS_SEARCH_THREADS to find another conversation when its thread_id is unknown. Raw provider payloads and developer/tool internals are not returned. Omit after and limit for full history; pass limit (1–200) to page from the start, then reuse next_cursor as after. Cursor reads default to 50 records and return immediately, even when empty. Merge records by record_id, replacing that record’s items on each revision; removed records delete prior items. Reads are independent and never consume another agent’s replies. Poll as often as your task needs; sending is always a separate explicit action."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "LOCAL_NOTIS_SEARCH_THREADS",
|
|
36
|
+
"description": "Find the current user's earlier conversations across channels, including voice calls. Search by title/latest inbound user-message preview and filter by channel, source, status, failure cause or date. A failed conversation has status 'failed' and names its saved cause in failure_cause. This is not full-transcript search: broaden a missed keyword query or list recent threads, then inspect likely matches with LOCAL_NOTIS_LOAD_CONVERSATION_HISTORY using the returned thread_id. Returns up to 50 summaries."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "LOCAL_NOTIS_SEARCH_MEMORIES",
|
|
40
|
+
"description": "Search the user's Supermemory knowledge, including long-term memories and synced native documents."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "LOCAL_NOTIS_SEARCH_HELP_CENTER",
|
|
44
|
+
"description": "Provide exactly one of query or guide_uri. Read a built-in Notis operating guide with guide_uri, or search Help Center documentation (help.notis.ai) with query for features, pricing, setup, troubleshooting and how-tos. Built-in guides come from canonical product Skills and require no shell or account-resource lookup."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "LOCAL_NOTIS_AUTHENTIFY",
|
|
48
|
+
"description": "Generate an authentication link or setup request for a toolkit such as Gmail, Google Calendar, PostForMe, or MCP. Use this to help the user connect or reconnect an app."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "LOCAL_NOTIS_LIST_CHANNEL_CONNECTIONS",
|
|
52
|
+
"description": "List the current user's connected messaging accounts across Telegram, WhatsApp, Messages, email, and Slack. Email rows cover every email address Notis recognizes for this user: the primary sign-in email (primary=true, cannot be disconnected) and each added address (alias). Returns safe exact selectors for status checks and disconnect previews."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "LOCAL_NOTIS_GET_CHANNEL_CONNECTION_STATUS",
|
|
56
|
+
"description": "Authoritatively read one native messaging channel for the current user. Messages results separate owned account state, recent inbound routing, provider-bridge configuration, recorded recipient restriction metadata (live provider state is not checked), and exact outbound receipt state. Report these facets separately; recorded restriction metadata is not a live provider check. provider_accepted means accepted for sending, not received; recipient delivery stays not_checked (unverified) unless a separate delivery receipt proves it. Third-party integration absence (LOCAL_NOTIS_GET_INTEGRATIONS_STATUS) and generic provider text do not prove disconnect; offer reconnect only when the returned account facet requires it. Pass connection_id for verification flows; Slack can be checked by channel after OAuth."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "LOCAL_NOTIS_BEGIN_CHANNEL_CONNECTION",
|
|
60
|
+
"description": "Start a provider-verification or authorization flow for one messaging channel. To add an email address or alias, pass channel=email with identifier: Notis emails a one-time confirmation link and also returns a prepared verification email. already_connected means the address is already on this account; code email_in_use means another account owns it. Never report the channel as connected until LOCAL_NOTIS_GET_CHANNEL_CONNECTION_STATUS reads back an owned account."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "LOCAL_NOTIS_DISCONNECT_CHANNEL_CONNECTION",
|
|
64
|
+
"description": "Preview or disconnect one exact owned messaging account, including an added email address (alias). Defaults to preview. The primary sign-in email cannot be disconnected. Disconnect pauses active dependent reminders and automations, clears their destination, and never reroutes or resumes them automatically."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "LOCAL_NOTIS_SEND_USER_MESSAGE",
|
|
68
|
+
"description": "Send a message to the Notis account owner on a connected channel or account, returning immediately after durable acceptance (not delivery or a user reply). Only for delivering a message to the user on another channel or account; never to answer in the current conversation (your final message is that answer) and never to message other people. Choose the destination explicitly on every send; no automatic channel forwarding. When an account selection is ambiguous, the result lists available selectors. Supply agent_name and agent_description on every call; optional context adds message-specific detail. Omit thread_id to create a topic or supply an existing account-owned thread_id to continue it. Do not resend a pending message. A send from an agent Notis runs (a Notis sub-agent) claims nothing: the user's reply in that thread goes to the Notis orchestrator like any other message, so do not poll for it. Every other accepted send, such as one from an agent outside Notis, claims its thread for your agent: latest send wins, and the orchestrator cannot answer or launch work in agent-owned threads. Poll LOCAL_NOTIS_LOAD_CONVERSATION_HISTORY with that thread_id at your chosen frequency for delivery status and all user replies; reuse its cursor for incremental reads."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "LOCAL_NOTIS_REPORT_BUG",
|
|
72
|
+
"description": "Report a bug or issue directly to Flo — the creator of Notis.ai. Use this when a user encounters a problem, error, or unexpected behavior and wants to report it, or asks to escalate a complaint to a human. The bug report will be sent to the team with the user's context (user_id, email, phone, thread_id) for investigation, and returns a ticket ID (e.g. NB-3F9A2C41) — always share it with the user so they can reference the report later. Works regardless of the user's subscription state."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "LOCAL_NOTIS_GET_INTEGRATIONS_STATUS",
|
|
76
|
+
"description": "Retrieves authentication status for the user's third-party integrations. It does not report Notis native messaging-channel state: an integration missing here is not a native channel disconnect, so never offer a channel reconnect from it. Use LOCAL_NOTIS_GET_CHANNEL_CONNECTION_STATUS for Telegram, WhatsApp, Messages, email, or Slack channel diagnostics."
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
```
|
|
28
80
|
|
|
29
81
|
### Rules
|
|
30
82
|
|
|
31
|
-
- Discovery first: never invent tool names, identifiers, or argument shapes.
|
|
32
83
|
- Cold discovery can exceed 30 seconds. Use `--timeout-ms 90000` for discovery calls when needed; after `network_timeout`, retry the exact command once with that timeout instead of looping.
|
|
33
|
-
- Before creating a reminder or automation, list existing ones to prevent duplicates. Confirm schedule, timezone, and delivery channel when ambiguous.
|
|
34
84
|
- Before writing a Notion or native database row, read the schema and relevant property descriptions.
|
|
35
85
|
- When answering about meetings or availability, check every connected calendar.
|
|
36
86
|
- Use `notis doctor` before concluding that authentication or API reachability is broken.
|
|
37
87
|
- Keep credentials and other secrets out of tool arguments and search queries unless the target tool explicitly requires them.
|
|
38
88
|
- If the active harness reports DNS or network restriction errors such as `ENOTFOUND`, host resolution failure, or `fetch failed`, rerun the same CLI request with that harness's network restrictions lifted rather than retrying unchanged.
|
|
89
|
+
|
|
39
90
|
<!-- notis-cli:instructions:end -->
|
|
@@ -48,12 +48,12 @@ The fields onboarding collects, wherever it runs. Both the conversational
|
|
|
48
48
|
onboarding assistant and the CLI brief reference this partial so the two paths
|
|
49
49
|
can never quietly collect different things.
|
|
50
50
|
|
|
51
|
-
All of these land through `LOCAL_NOTIS_SAVE_USER_SETTINGS`: `
|
|
51
|
+
All of these land through `LOCAL_NOTIS_SAVE_USER_SETTINGS`: `first_name` on the
|
|
52
52
|
user row, the rest merged into the `settings` blob.
|
|
53
53
|
|
|
54
54
|
| Field | Setting key | What it is for |
|
|
55
55
|
|---|---|---|
|
|
56
|
-
| First name | `
|
|
56
|
+
| First name | `first_name` | How Notis addresses the user. Ask for their given name; preserve compound names and do not infer it from an email address. |
|
|
57
57
|
| Occupation / role | `position` | Tailors examples and suggestions. |
|
|
58
58
|
| Language | `language` | The language Notis replies in. |
|
|
59
59
|
| Time zone | `timezone` | Anchors every scheduled and time-relative request. Ask; never guess silently. |
|
|
@@ -63,8 +63,9 @@ Rules that hold on every surface:
|
|
|
63
63
|
|
|
64
64
|
* Ask one question at a time. A wall of questions reads as a form, and people
|
|
65
65
|
abandon forms.
|
|
66
|
-
*
|
|
67
|
-
|
|
66
|
+
* For first name, skip only when already saved or provided by the user. Never
|
|
67
|
+
deduce a name. For other fields, skip known or confidently deducible values
|
|
68
|
+
and say what you deduced rather than asking the user to confirm a blank.
|
|
68
69
|
* Save as soon as you have the basics rather than batching to the end — a user who
|
|
69
70
|
drops out halfway should not lose what they already told you.
|
|
70
71
|
|
|
@@ -74,7 +75,7 @@ what you inferred, and let the user correct you. Then save:
|
|
|
74
75
|
|
|
75
76
|
```bash
|
|
76
77
|
npx --package @notis_ai/cli@latest -- notis tools exec LOCAL_NOTIS_SAVE_USER_SETTINGS \
|
|
77
|
-
--arguments '{"
|
|
78
|
+
--arguments '{"first_name":"...","position":"...","language":"...","timezone":"..."}'
|
|
78
79
|
```
|
|
79
80
|
|
|
80
81
|
## 2. Connect one app, then immediately read from it
|
|
@@ -93,9 +94,13 @@ through the connection and show them the actual result: today's calendar events,
|
|
|
93
94
|
their most recent email threads, the databases in their Notion. A connected
|
|
94
95
|
integration is a claim; their own data on screen is proof.
|
|
95
96
|
|
|
96
|
-
If they connect nothing,
|
|
97
|
-
|
|
98
|
-
`
|
|
97
|
+
If they connect nothing, you may offer a Space from the Space Store instead, but
|
|
98
|
+
only on an account that has the Store: it is not open to every account yet. Check
|
|
99
|
+
with `notis spaces store list` first. If it answers that Spaces or the Store are
|
|
100
|
+
not available for this account (`spaces_unavailable` or
|
|
101
|
+
`spaces_store_unavailable`), skip this fallback without retrying and go to
|
|
102
|
+
step 3. Otherwise install one with
|
|
103
|
+
`notis spaces store install <listing-id> --request-id <stable-id>`.
|
|
99
104
|
|
|
100
105
|
## 3. Finish onboarding
|
|
101
106
|
|
|
@@ -125,4 +130,4 @@ in this document.
|
|
|
125
130
|
|
|
126
131
|
What they *do* have: 1,000+ integrations through this CLI, skills that sync to
|
|
127
132
|
their coding agents, long-term memory, notes and databases, and the ability to
|
|
128
|
-
build Notis
|
|
133
|
+
build Notis Spaces from this machine.
|
|
@@ -13,7 +13,7 @@ For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOT
|
|
|
13
13
|
- `LOCAL_NOTIS_DATABASE_LIST_DATABASES` — list native databases.
|
|
14
14
|
- `LOCAL_NOTIS_DATABASE_GET_DATABASE` — inspect one database schema.
|
|
15
15
|
- `LOCAL_NOTIS_DATABASE_QUERY` — query native database documents.
|
|
16
|
-
- `LOCAL_NOTIS_DATABASE_UPSERT_DATABASE` — create or update database schema. Creation
|
|
16
|
+
- `LOCAL_NOTIS_DATABASE_UPSERT_DATABASE` — create or update database schema. Creation links the database to at least one Space with `links.add` and names its main view in that Space with `main_view`.
|
|
17
17
|
|
|
18
18
|
## Workflow
|
|
19
19
|
|