From 555fc5947bc9a45588bc2a4a15b2f5080c3eb289 Mon Sep 17 00:00:00 2001 From: eaczydot Date: Sun, 16 Aug 2026 20:59:31 -0400 Subject: [PATCH 1/5] Add Linear typography and provider integration plans --- App.tsx | 31 ++- app.json | 3 +- package-lock.json | 48 ++-- package.json | 2 + plans/001-linear-typography.md | 192 +++++++++++++++ plans/002-provider-favicons.md | 199 +++++++++++++++ plans/003-firecrawl-runtime.md | 338 ++++++++++++++++++++++++++ plans/004-firecrawl-slash-commands.md | 301 +++++++++++++++++++++++ plans/README.md | 69 ++++++ src/genos/GenOS.tsx | 2 +- src/genos/shell/HomeScreen.tsx | 10 +- src/genos/shell/KeyGate.tsx | 5 +- src/genos/shell/Switcher.tsx | 3 +- src/genos/typography.tsx | 131 ++++++++++ src/genos/ui/cupertino/components.tsx | 25 +- src/genos/ui/cupertino/forms.tsx | 3 +- src/genos/ui/material/components.tsx | 21 +- src/genos/ui/material/forms.tsx | 3 +- src/genos/ui/shared/charts.tsx | 3 +- 19 files changed, 1316 insertions(+), 73 deletions(-) create mode 100644 plans/001-linear-typography.md create mode 100644 plans/002-provider-favicons.md create mode 100644 plans/003-firecrawl-runtime.md create mode 100644 plans/004-firecrawl-slash-commands.md create mode 100644 plans/README.md create mode 100644 src/genos/typography.tsx diff --git a/App.tsx b/App.tsx index f871dfd..c654497 100644 --- a/App.tsx +++ b/App.tsx @@ -1,18 +1,41 @@ import { StatusBar } from "expo-status-bar"; +import { + Inter_300Light, + Inter_400Regular, + Inter_500Medium, + Inter_600SemiBold, + Inter_700Bold, + Inter_800ExtraBold, + useFonts, +} from "@expo-google-fonts/inter"; import React, { useEffect } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import GenOS from "./src/genos/GenOS"; import { initTelemetry } from "./src/genos/telemetry"; +import { TypographyProvider } from "./src/genos/typography"; export default function App() { + const [fontsLoaded, fontError] = useFonts({ + Inter_300Light, + Inter_400Regular, + Inter_500Medium, + Inter_600SemiBold, + Inter_700Bold, + Inter_800ExtraBold, + }); + useEffect(() => { initTelemetry(); }, []); + if (!fontsLoaded && !fontError) return null; + return ( - - - - + + + + + + ); } diff --git a/app.json b/app.json index 3686a72..c278df1 100644 --- a/app.json +++ b/app.json @@ -22,7 +22,8 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - "expo-secure-store" + "expo-secure-store", + "expo-font" ] } } diff --git a/package-lock.json b/package-lock.json index 55ce88a..b4af91b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@expo-google-fonts/inter": "^0.4.2", "@expo/metro-runtime": "~6.1.2", "@openuidev/react-lang": "0.1.5", "@react-native-community/slider": "5.0.1", "expo": "^54.0.0", + "expo-font": "~14.0.12", "expo-linear-gradient": "~15.0.8", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", @@ -1548,6 +1550,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@expo-google-fonts/inter": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/inter/-/inter-0.4.2.tgz", + "integrity": "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==", + "license": "MIT AND OFL-1.1" + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -5708,6 +5716,20 @@ } } }, + "node_modules/expo-font": { + "version": "14.0.12", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", + "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", @@ -6184,20 +6206,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-font": { - "version": "14.0.12", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", - "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", - "license": "MIT", - "dependencies": { - "fontfaceobserver": "^2.1.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -9548,9 +9556,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9571,9 +9576,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9594,9 +9596,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9617,9 +9616,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 42ce9aa..6f9838e 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "main": "index.ts", "dependencies": { + "@expo-google-fonts/inter": "^0.4.2", "@expo/metro-runtime": "~6.1.2", "@openuidev/react-lang": "0.1.5", "@react-native-community/slider": "5.0.1", "expo": "^54.0.0", + "expo-font": "~14.0.12", "expo-linear-gradient": "~15.0.8", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", diff --git a/plans/001-linear-typography.md b/plans/001-linear-typography.md new file mode 100644 index 0000000..58d9524 --- /dev/null +++ b/plans/001-linear-typography.md @@ -0,0 +1,192 @@ +# Plan 001: Finish and verify Linear-style global typography + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving on. If a +> STOP condition occurs, stop and report it rather than improvising. When done, +> update this plan's row in `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 9867af9 -- App.tsx package.json package-lock.json app.json src/genos/typography.tsx src/genos/GenOS.tsx src/genos/shell src/genos/ui` +> The plan was written against uncommitted typography changes, so a non-empty +> diff is expected. Compare the current-state excerpts below before editing. If +> they no longer match in substance, stop and report drift. + +## Status + +- **Priority**: P1 +- **Effort**: S (hours) +- **Risk**: LOW +- **Depends on**: none +- **Category**: direction +- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes + +## Why this matters + +The requested Linear-style font rollout is already mostly present in the working +tree, but it lacks direct typography tests and a documented definition of +"Linear font." Linear's current public CSS uses Inter Variable as its regular +UI family. AppLess now loads static Inter weight files through Expo, which is the +portable React Native equivalent, but the change should not be considered done +until every native `Text`/`TextInput` path and weight fallback is verified. + +## Current state + +- `App.tsx:2-25` imports Inter 300 through 800 from + `@expo-google-fonts/inter` and loads them with `useFonts`. +- `App.tsx:31-39` waits for loading or an error, then supplies the loaded state + through `TypographyProvider`. +- `src/genos/typography.tsx:12-66` maps numeric weights to named Inter font + families and wraps React Native `Text` and `TextInput`. +- `src/genos/typography.tsx:69-131` defines a `linearType` scale. +- All current JSX text imports have been moved to `src/genos/typography.tsx`; + `react-native-svg` text remains separate by design. +- `package.json` already contains `@expo-google-fonts/inter` and `expo-font`, and + `app.json` already lists the `expo-font` plugin. +- Current verification: `npm run typecheck` passes and + `npm test -- --runInBand --no-watchman` passes 22 tests. + +Reference evidence: + +- Linear currently declares `--font-regular: "Inter Variable", ...` and uses + weights around 400/510/590/680. +- Linear also declares Berkeley Mono for monospace, but this app has no + monospace semantic role. Do not add it globally. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Typecheck | `npm run typecheck` | exit 0, no TypeScript errors | +| Tests | `npm test -- --runInBand --no-watchman` | all suites and tests pass | +| Raw text audit | `rg -n '(^|[,{ ])Text(Input)?([, }]| as )' src App.tsx -g '*.tsx'` | only typography wrapper aliases or intentional SVG text remain | + +## Scope + +**In scope**: + +- `App.tsx` +- `app.json` +- `package.json` +- `package-lock.json` +- `src/genos/typography.tsx` +- Existing `.tsx` files already changed to import the typography wrappers +- `__tests__/typography.test.tsx` (create) +- `README.md` only for a short typography note if needed + +**Out of scope**: + +- Changing colors, spacing, layout, icons, or wallpaper +- Adding Berkeley Mono to regular UI text +- Downloading fonts from Linear's CDN +- Replacing SVG chart text; SVG text does not accept React Native `Text` +- Reworking the generated OpenUI component contract + +## Git workflow + +- Branch: `advisor/001-linear-typography` +- Existing commits use imperative sentence case, for example + `Add anonymous run analytics (PostHog) (#4)`; use the same style. +- Do not push or open a PR unless instructed. +- Preserve all pre-existing uncommitted changes; this plan finishes them rather + than recreating or reverting them. + +## Steps + +### Step 1: Lock the semantic font behavior with tests + +Create `__tests__/typography.test.tsx`. Render `Text` and `TextInput` inside +`TypographyProvider` with `react-test-renderer` and assert: + +- loaded + no weight selects `Inter_400Regular`; +- weights 300, 500, 600, 700, and 800 select the matching named family; +- a style array is flattened correctly; +- an explicit `fontFamily` is preserved; +- `loaded={false}` selects the platform system fallback; +- the wrapper writes `fontWeight: "normal"` after selecting the correct file so + React Native does not synthetically re-weight a named font file. + +Do not export private implementation details solely for testing unless rendering +cannot observe the final style. + +**Verify**: +`npm test -- --runInBand --no-watchman __tests__/typography.test.tsx` -> the new +suite passes. + +### Step 2: Finish the global import audit + +Audit every `.tsx` file under `src/` and `App.tsx`. Ordinary text must import +`Text` and `TextInput` from `src/genos/typography.tsx` (using the correct relative +path). The only permitted exceptions are: + +- `NativeText` and `NativeTextInput` aliases inside `typography.tsx`; +- `SvgText` from `react-native-svg` in chart rendering. + +Keep existing size and weight values unless a value is moved to an already +defined `linearType` token without changing rendered hierarchy. + +**Verify**: +`rg -n 'Text(Input)?[^\n]*from "react-native"' src App.tsx -g '*.tsx'` -> no +ordinary UI imports are returned. + +### Step 3: Verify loading and fallback behavior + +Confirm `App.tsx` never displays a partially loaded mixed-font UI. Keep the +current behavior of returning `null` while fonts are loading, but allow the app +to render with system fonts when `useFonts` reports an error. Add a focused test +only if this requires logic beyond the existing condition. + +Check `app.json` has exactly one `expo-font` plugin entry and that package and +lockfile versions agree. + +**Verify**: `npm run typecheck` -> exit 0. + +### Step 4: Run cross-renderer regression tests + +Run the complete render suites because the wrapper touches both Cupertino and +Material component libraries. + +**Verify**: `npm test -- --runInBand --no-watchman` -> all suites pass, including +the new typography suite. + +## Test plan + +- New `__tests__/typography.test.tsx` coverage as described in Step 1. +- Existing `__tests__/render.test.tsx` verifies Cupertino components still + render through the OpenUI pipeline. +- Existing `__tests__/render-material.test.tsx` verifies Material components. +- Manual device check after automated tests: one iOS and one Android launch, + checking home, key gate, generated list, form input, chart labels, switcher, + and error/retry state for missing glyphs or synthetic bold. + +## Done criteria + +- [ ] `npm run typecheck` exits 0. +- [ ] `npm test -- --runInBand --no-watchman` exits 0. +- [ ] Typography tests cover regular, all used weights, explicit family, style + arrays, and unloaded fallback. +- [ ] No ordinary UI component imports `Text` or `TextInput` directly from + `react-native`. +- [ ] Inter is used for global UI copy on iOS, Android, and web. +- [ ] No unrelated visual values were changed. +- [ ] `plans/README.md` status is updated. + +## STOP conditions + +Stop and report if: + +- the current Inter wrapper or `App.tsx` loading code no longer matches the + current-state description; +- a platform cannot resolve the named Inter families loaded by `useFonts`; +- completing the audit requires changing generated OpenUI source or SVG text; +- tests still fail twice when run with `--no-watchman`. + +## Maintenance notes + +- If AppLess later adds code blocks or structured raw data, introduce a separate + monospace token; Berkeley Mono requires its own redistribution/license review. +- Reviewers should scrutinize `fontWeight` ordering in style arrays. The wrapper + intentionally converts requested weight to a concrete family, then resets + native weight to normal. +- React Native variable-font behavior differs by platform, so do not replace + the named files with a single variable font without device-level proof. + diff --git a/plans/002-provider-favicons.md b/plans/002-provider-favicons.md new file mode 100644 index 0000000..92870b5 --- /dev/null +++ b/plans/002-provider-favicons.md @@ -0,0 +1,199 @@ +# Plan 002: Add Raycast-compatible provider favicons + +> **Executor instructions**: Follow this plan step by step and run every +> verification gate. Stop on any listed STOP condition. Update the matching row +> in `plans/README.md` when done. +> +> **Drift check (run first)**: +> `git diff --stat 9867af9 -- src/genos/apps.ts src/genos/GenOS.tsx src/genos/shell/HomeScreen.tsx src/genos/shell/Switcher.tsx` +> Compare the excerpts below against the working tree because these paths have +> pre-existing uncommitted UI changes. + +## Status + +- **Priority**: P1 +- **Effort**: S (hours) +- **Risk**: LOW +- **Depends on**: none +- **Category**: direction +- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes + +## Why this matters + +Provider-backed commands need recognizable, consistent branding in the command +menu, minimized home tiles, and app switcher. Raycast's `getFavicon` establishes +the desired behavior, but the helper cannot be imported into Expo because it +returns `@raycast/api` image types. A tiny app-native resolver can use the same +Raycast favicon service and provide deterministic fallbacks without adding an +incompatible runtime. + +## Current state + +- `src/genos/apps.ts:1-9` defines `AppDef` with only `id`, `name`, `emoji`, tile + colors, and a generation request. +- `src/genos/GenOS.tsx` copies only `name`, `emoji`, and `tile` into `AppMeta` and + `RunningApp`. +- `src/genos/shell/HomeScreen.tsx:127-190` maps emoji and keywords to Phosphor + icons; it has no provider concept. +- `src/genos/shell/Switcher.tsx:10-15` defines the same minimal `RunningApp` + metadata and renders the emoji in a gradient. +- Raycast's open-source implementation normalizes a host and constructs + `https://api.ray.so/favicon?url=&size=`, defaulting to 64px and + a link-icon fallback. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Typecheck | `npm run typecheck` | exit 0 | +| Tests | `npm test -- --runInBand --no-watchman` | all pass | +| Dependency audit | `npm ls @raycast/api @raycast/utils` | both absent; a nonzero npm result is acceptable only because they are not installed | + +## Suggested executor toolkit + +- Reference Raycast's official docs: + `https://developers.raycast.com/utilities/icons/getfavicon`. +- Reference the open-source implementation: + `https://github.com/raycast/utils/blob/main/src/icon/favicon.ts`. + +## Scope + +**In scope**: + +- `src/genos/providers.ts` (create) +- `src/genos/ui/ProviderIcon.tsx` (create) +- `src/genos/apps.ts` +- `src/genos/GenOS.tsx` +- `src/genos/shell/HomeScreen.tsx` +- `src/genos/shell/Switcher.tsx` +- `__tests__/providers.test.tsx` (create) + +**Out of scope**: + +- Installing `@raycast/api` or `@raycast/utils` +- Scraping arbitrary pages to discover `` +- Changing built-in app icons that already use Phosphor glyphs +- Storing provider credentials +- Adding slash-command UI or Firecrawl network calls (Plans 003 and 004) + +## Git workflow + +- Branch: `advisor/002-provider-favicons` +- Match the repository's imperative sentence-case commit messages. +- Do not push or open a PR unless instructed. +- Preserve the current uncommitted typography changes. + +## Steps + +### Step 1: Create a typed provider registry + +Create `src/genos/providers.ts` with: + +```ts +export interface ProviderDef { + id: string; + name: string; + domain: string; + homepage: string; + tile: [string, string]; + fallbackGlyph: string; +} +``` + +Add a `firecrawl` entry for `firecrawl.dev`. Export a lookup that returns +`undefined` for unknown IDs. Keep provider metadata separate from slash command +metadata so more commands can share one provider. + +Add a pure `raycastFaviconUrl(domainOrUrl, size = 64)` function that: + +- accepts a bare host or HTTPS URL; +- normalizes with `new URL`, adding `https://` when missing; +- uses only `.hostname` in the request; +- clamps size to a small safe range such as 16-256; +- returns `https://api.ray.so/favicon?url=${encodeURIComponent(hostname)}&size=${size}`; +- returns `null` for malformed input instead of throwing. + +**Verify**: `npm run typecheck` -> exit 0. + +### Step 2: Add a React Native provider icon with fallback + +Create `src/genos/ui/ProviderIcon.tsx`. It should accept `providerId`, `size`, and +an optional corner radius. Resolve the provider, render a React Native `Image` +for the Raycast URL, and switch to a local Phosphor `Globe`/`Link` style glyph on +load error or invalid metadata. Reset the error state when `providerId` changes. +Give the image an accessibility label such as `Firecrawl provider`. + +Do not block app launch on icon fetches. Do not render a blank square while the +favicon is unavailable. + +**Verify**: focused tests can render success metadata and force the error path +without network access. + +### Step 3: Carry provider identity through session metadata + +Add optional `providerId?: string` to `AppDef`, the internal `AppMeta`, and +`RunningApp`. Ensure `launch`, deep-link metadata, the `runningApps` memo, home +resume state, and switcher state preserve it. Existing built-in apps must behave +unchanged when no provider is present. + +Use `ProviderIcon` in: + +- the home/minimized tile when `providerId` exists; +- the switcher header icon and empty preview when `providerId` exists. + +Keep the existing Phosphor/emoji fallback for all non-provider apps. + +**Verify**: `npm run typecheck` -> exit 0. + +### Step 4: Add resolver and rendering tests + +Create `__tests__/providers.test.tsx` covering: + +- bare domain and full URL normalization; +- path/query removal; +- size default and clamp; +- malformed input returns `null`; +- known and unknown provider lookup; +- provider icon fallback after an image error; +- built-in app metadata remains provider-free. + +**Verify**: `npm test -- --runInBand --no-watchman __tests__/providers.test.tsx` +-> the new suite passes. + +## Test plan + +- Pure URL and registry tests in `__tests__/providers.test.tsx`. +- Component fallback test using `react-test-renderer`; invoke the `Image` error + handler directly rather than making a network request. +- Existing full render suites must continue to pass. +- Manual offline check: Firecrawl session shows a local fallback icon and all + navigation remains usable. + +## Done criteria + +- [ ] No Raycast package is installed. +- [ ] The resolver matches Raycast's `api.ray.so/favicon` hostname/size shape. +- [ ] Invalid URLs and image errors produce a visible local fallback. +- [ ] Provider ID survives launch, minimize, resume, switcher, and close flows. +- [ ] Built-in app icons are visually unchanged. +- [ ] `npm run typecheck` and the complete no-Watchman test suite pass. +- [ ] `plans/README.md` status is updated. + +## STOP conditions + +Stop and report if: + +- Raycast changes the official provider URL or prohibits this use; +- `api.ray.so` cannot be loaded by React Native on either iOS or Android; +- provider identity would require persisting arbitrary remote image URLs instead + of a trusted registry ID; +- required edits expand into generated OpenUI component files. + +## Maintenance notes + +- Centralize the service URL in the resolver so a future provider/fallback can + be swapped without touching UI components. +- Reviewers should check URL parsing, encoding, image error loops, offline + behavior, and accessibility labels. +- Treat favicons as decoration, never as proof of provider identity or auth. + diff --git a/plans/003-firecrawl-runtime.md b/plans/003-firecrawl-runtime.md new file mode 100644 index 0000000..ae3450f --- /dev/null +++ b/plans/003-firecrawl-runtime.md @@ -0,0 +1,338 @@ +# Plan 003: Add the Firecrawl provider runtime + +> **Executor instructions**: Follow the steps in order, run each verification, +> and stop on any STOP condition. This plan adds real credit-bearing network +> operations; do not substitute guessed endpoint behavior. Update the row in +> `plans/README.md` when done. +> +> **Drift check (run first)**: +> `git diff --stat 9867af9 -- src/config.ts src/genos/stream.ts src/genos/store.ts src/genos/tools __tests__/tools.test.ts` +> Compare the current-state excerpts before editing. + +## Status + +- **Priority**: P1 +- **Effort**: L (multi-day) +- **Risk**: HIGH +- **Depends on**: none +- **Category**: direction +- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes + +## Why this matters + +The requested Firecrawl workflows cannot be delivered by adding labels alone. +AppLess currently exposes one synchronous Exa `web_search` tool to Cerebras. +Directory extraction, lead generation, lead research, and deep research need a +real Firecrawl execution layer with provider-scoped instructions, structured +outputs, async Agent polling, cancellation, explicit credit caps, and BYOK key +handling. This is the highest-risk plan because it spends external credits and +can run for minutes. + +## Current state + +- `src/config.ts:14-16` supports optional public Exa and Unsplash keys, while the + Cerebras key uses a device-backed store. +- `src/genos/tools/search.ts:25-66` hard-codes one OpenAI tool definition and one + name switch for `web_search`. +- `src/genos/stream.ts:14` imports the search module directly. +- `src/genos/stream.ts:39-40` allows at most three tool rounds. +- `src/genos/stream.ts:249-287` awaits tool results inline before asking + Cerebras to compose the screen. +- `src/genos/store.ts:8-33` has no workflow/provider context on a screen. +- `src/genos/store.ts:211-220` correctly refuses every tool call during + speculative prefetch; preserve this credit-safety rule. +- Firecrawl's current API uses `POST https://api.firecrawl.dev/v2/search`, + `POST /v2/scrape`, `POST /v2/agent`, and `GET /v2/agent/` for status. + Agent states include `processing`, `completed`, `failed`, and `cancelled`. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Typecheck | `npm run typecheck` | exit 0 | +| Unit tests | `npm test -- --runInBand --no-watchman __tests__/tools.test.ts __tests__/store.test.ts` | all pass | +| Full tests | `npm test -- --runInBand --no-watchman` | all pass | + +## Suggested executor toolkit + +- Read the current official Firecrawl docs before coding: + `https://docs.firecrawl.dev/introduction` and + `https://docs.firecrawl.dev/features/agent`. +- Use the Node/REST examples as the protocol source of truth; do not add the + Node SDK unless it is verified to work in Expo/Hermes and materially reduces + code. Direct `expo/fetch` matches the repository's current convention. + +## Scope + +**In scope**: + +- `src/config.ts` +- `src/genos/providers/firecrawl/key.ts` (create; path may be + `src/genos/tools/firecrawl/key.ts` if provider folders are not adopted) +- `src/genos/shell/ProviderKeyGate.tsx` (create) +- `src/genos/tools/index.ts` (create) +- `src/genos/tools/search.ts` +- `src/genos/tools/firecrawl.ts` (create) +- `src/genos/stream.ts` +- `src/genos/store.ts` +- `src/genos/workflows.ts` (create compact workflow contracts) +- `__tests__/tools.test.ts` +- `__tests__/store.test.ts` +- `__tests__/firecrawl.test.ts` (create) +- `README.md` and `.env` documentation, never a real key + +**Out of scope**: + +- Slash-command menu and command discovery (Plan 004) +- Provider favicons (Plan 002) +- Bundling local Codex `SKILL.md` files +- Automatically submitting CRM records or sending outreach +- Bypassing login walls, CAPTCHAs, robots restrictions, or paywalls +- Uploading CSV files in the first release +- A shared production Firecrawl key in an Expo public environment variable + +## Git workflow + +- Branch: `advisor/003-firecrawl-runtime` +- Match imperative sentence-case commit messages. +- Prefer logical commits: tool registry, Firecrawl client/key store, workflow + context, then tests/docs. +- Do not push or open a PR unless instructed. + +## Steps + +### Step 1: Generalize the tool registry without changing Exa behavior + +Create `src/genos/tools/index.ts` as the single source for: + +- enabled OpenAI-format tool definitions; +- enabled prompt sections; +- `executeTool(name, args, signal, progress?)` dispatch; +- provider availability. + +Move no Exa network behavior unless necessary. `web_search` must keep its exact +name, request shape, result formatting, error-as-string behavior, and existing +tests. Update `stream.ts` to import only from the new registry. + +Avoid one global `toolsAvailable()` boolean that exposes disabled provider tools. +Build definitions from the keys/providers actually available for the active +screen. + +**Verify**: existing tool tests and `npm run typecheck` pass before Firecrawl is +added. + +### Step 2: Add a Firecrawl BYOK key store + +Model the existing Cerebras key store but keep provider status separate. Store a +user-entered key in SecureStore on iOS/Android and localStorage on web under a +Firecrawl-specific key. Support `loading`, `missing`, `present`, and `rejected`. +Clear only the exact key rejected by a 401/403 response. + +An optional development-only environment key may be supported for local builds, +but document that any `EXPO_PUBLIC_*` secret is bundled into the client and must +not be used as a shared production credential. + +Do not make Firecrawl mandatory for ordinary AppLess use. Missing Firecrawl +credentials disable Firecrawl tools but leave Cerebras and Exa flows intact. + +Add a reusable, non-global `ProviderKeyGate` for entering/replacing the key. It +must identify Firecrawl, explain on-device storage and credit usage, link to the +official key page, and be shown only when a Firecrawl action needs it. It must +never replace the existing Cerebras startup gate. + +**Verify**: unit tests mock SecureStore and cover hydrate, set, reject-current, +do-not-reject-replaced-key, and key-gate dismissal without affecting Cerebras. + +### Step 3: Implement Firecrawl primitives + +In `src/genos/tools/firecrawl.ts`, implement small typed clients using +`expo/fetch`: + +- `firecrawl_search`: `POST /v2/search` with query, a bounded limit, and optional + scrape formats; +- `firecrawl_scrape`: `POST /v2/scrape` for one validated HTTP(S) URL and bounded + markdown/JSON output; +- `firecrawl_agent`: `POST /v2/agent` with prompt, optional trusted schema/model/ + URLs, and an explicit `maxCredits` supplied by the workflow contract; +- `getFirecrawlAgentStatus`: `GET /v2/agent/`. + +For every endpoint: + +- attach `Authorization: Bearer ` and JSON headers; +- validate arguments before network access; +- allow only `http:` and `https:` URLs; +- cap result counts and text passed back to Cerebras to stay within context; +- never log the key, authorization headers, or raw sensitive result bodies; +- mark the current key rejected on 401/403; +- map 429 and credit-limit errors to actionable error text; +- preserve source URLs and explicit missing fields; +- return error strings to the model rather than inventing data. + +Do not expose arbitrary JSON schema supplied verbatim by the model. Choose a +schema from a trusted `workflowId` registry or validate it against a strict size +and depth budget. + +**Verify**: mocked fetch tests assert endpoint, method, sanitized body, auth +presence without snapshotting the secret, and response/error handling. + +### Step 4: Make Agent execution asynchronous, cancellable, and bounded + +Implement polling as a dedicated helper, not an unbounded loop inside +`executeTool`: + +- poll only while status is `processing`; +- use a 2-5 second delay with a documented maximum elapsed time based on the + workflow depth; +- stop immediately when the screen's AbortSignal fires; +- call the documented Agent cancellation endpoint on user cancellation if the + current API supports it; otherwise stop local polling and document that the + remote job may continue consuming credits; +- return `completed` data plus `creditsUsed` and source metadata; +- map `failed` and `cancelled` distinctly; +- time out with the job ID retained in a safe error so a later status check can + recover it; +- never silently retry a new Agent job after a timeout. + +Expose progress states such as `starting`, `processing`, and elapsed time to the +screen store. Replace the current boolean-only `searching` state with a backwards +compatible tool-progress shape or add a new optional field. The shell must still +be usable while the job runs. + +**Verify**: fake-timer tests cover processing -> completed, processing -> failed, +timeout, abort, and no duplicate POST on retry/status recovery. + +### Step 5: Persist workflow context through screen navigation + +Add `workflowId?: string` to `AppDef`, `Screen`, and `LaunchInput`. `openApp` +copies it to the first screen; `resolveAction`, prefetch children, retry, and deep +navigation inherit it from the parent. Do not paste the full workflow prompt into +every user request. + +Build a compact trusted workflow contract from `workflowId` and append it to the +system prompt for that screen. The runtime is not complete until it has contracts +for the full catalog below. Related workflows may share primitives and output +helpers, but each ID needs its own input requirements, collection policy, result +shape, and tests: + +- `firecrawl-company-directories`: visible fields, dedupe, pagination progress, + and blanks for unavailable data; +- `firecrawl-competitive-intel`: current pricing/features/changelog evidence, + timestamped comparisons, and explicit conflicting or missing claims; +- `firecrawl-dashboard-reporting`: authorized dashboard/session boundaries, + metric definitions, reporting period, and no credential capture in output; +- `firecrawl-deep-research`: explicit quick/thorough/exhaustive depth, citations, + synthesis, risks, and open questions; +- `firecrawl-demo-walkthrough`: bounded product flow, observed UX evidence, and + no state-changing action without explicit permission; +- `firecrawl-knowledge-base` and `firecrawl-knowledge-ingest`: scoped sources, + crawl boundaries, provenance, dedupe, update timestamp, and login limitations; +- `firecrawl-lead-gen`: legitimately accessible fields only, dedupe, data gaps, + and no access-control bypass; +- `firecrawl-lead-research`: concise sourced brief with facts separated from + inferred pain points; +- `firecrawl-market-research`: dated metrics, primary-source preference, + methodology, and uncertainty; +- `firecrawl-qa`: bounded target and charter, reproducible steps, evidence, and + no destructive submissions; +- `firecrawl-research-papers`: paper metadata, primary PDF/source links, + methodology, results, limitations, and no invented citations; +- `firecrawl-seo-audit`: crawl boundary, metadata/indexability evidence, + prioritized findings, and page samples; +- `firecrawl-shop`: constraints, current price/availability evidence, + comparisons, and no purchase action; +- `firecrawl-website-design-clone`: observed tokens/components, asset provenance, + and a DESIGN.md-shaped result without copying protected content wholesale; +- `firecrawl-workflows`: a safe chooser that routes to one concrete contract + rather than acting as an unbounded generic Agent; +- base `firecrawl`: an explicit search, scrape, or Agent choice, with Agent + requiring cost/depth confirmation. + +All workflow screens must preserve source URLs and must not fabricate missing +email, phone, funding, roles, or company facts. + +**Verify**: store tests assert workflow inheritance across root, action child, +form child, prefetch, and retry. + +### Step 6: Define cost and depth policy + +Every Agent call must receive an explicit `maxCredits`; never rely on the current +API default of 2,500. Put budget ranges in workflow definitions and require the +UI/command layer to select one before execution. Recommended initial policy: + +- base search/scrape: no Agent call; +- structured directory, lead, shopping, SEO, QA, knowledge, dashboard, and demo + workflows: 150 credits by default, with requested row/page limits included in + the prompt and schema; +- competitive and market research: 250 credits by default; +- deep research: quick = 100, thorough = 300, exhaustive = 750 credits, with + increasing timeouts and a separate confirmation for exhaustive; +- pro model only for an explicit high-accuracy choice, never by default. + +Treat these as maximums, not expected spend. Validate them with disposable +low-credit test runs before production and adjust only in the central policy, +never in individual UI components. + +**Verify**: a unit test fails if any Agent definition can execute with missing or +non-positive `maxCredits`. + +### Step 7: Document and verify the provider runtime + +Update README setup text with Firecrawl BYOK behavior, supported primitives, +credit/cost warning, and limitations. Do not claim the specialized slash commands +exist until Plan 004 lands. + +Run the full test suite and one manual smoke test per endpoint with a disposable +low-credit key. The manual test must not use real personal lead data. + +**Verify**: `npm run typecheck` and +`npm test -- --runInBand --no-watchman` both exit 0. + +## Test plan + +- `__tests__/firecrawl.test.ts`: request validation, auth rejection, 429/credit + error, content truncation, source retention, polling state machine, abort, and + explicit credit cap. +- `__tests__/tools.test.ts`: registry composition with no keys, Exa only, + Firecrawl only, and both providers. +- `__tests__/store.test.ts`: workflow context inheritance and no speculative + credit-bearing calls. +- Manual low-credit smoke tests: search, scrape one public page, one tiny Agent + structured extraction, status polling, and cancel. + +## Done criteria + +- [ ] Missing Firecrawl credentials do not affect ordinary AppLess behavior. +- [ ] No shared Firecrawl secret appears in source, public config, logs, tests, or + snapshots. +- [ ] Every Agent run has an explicit timeout, AbortSignal, and `maxCredits`. +- [ ] Speculative prefetch never executes Firecrawl. +- [ ] Every Firecrawl catalog ID has a trusted runtime contract and tests. +- [ ] Workflow context survives every child screen. +- [ ] Facts retain source URLs; unavailable structured fields stay blank. +- [ ] `npm run typecheck` and all no-Watchman tests pass. +- [ ] README documents BYOK, cost, and current limitations. +- [ ] `plans/README.md` status is updated. + +## STOP conditions + +Stop and report if: + +- Firecrawl's current API differs from the documented v2 endpoint/state model; +- Firecrawl does not support required cross-origin/native requests and the fix + would require introducing a backend; +- cancellation cannot prevent or bound credit usage sufficiently for product + approval; +- Cerebras rejects the expanded tool schema or cannot handle the tool outputs; +- real execution requires CAPTCHA/access-control bypass; +- production would require caps above the central policy without a new explicit + product decision. + +## Maintenance notes + +- Firecrawl Agent was marked Research Preview in the documentation reviewed for + this plan; endpoint shapes and pricing can change. Keep protocol code isolated. +- Reviewers should scrutinize credit caps, duplicate job creation, abort behavior, + schema trust boundaries, secret handling, and output-size limits. +- Long research is fundamentally different from the current sub-second screen + generation path. Preserve job IDs so future versions can resume across app + restarts without rerunning paid work. diff --git a/plans/004-firecrawl-slash-commands.md b/plans/004-firecrawl-slash-commands.md new file mode 100644 index 0000000..beceb59 --- /dev/null +++ b/plans/004-firecrawl-slash-commands.md @@ -0,0 +1,301 @@ +# Plan 004: Add the Firecrawl slash-command catalog and menu + +> **Executor instructions**: Implement only after Plans 002 and 003 are DONE. +> Follow each verification gate and stop on any STOP condition. Update the row in +> `plans/README.md` when done. +> +> **Drift check (run first)**: +> `git diff --stat 9867af9 -- src/genos/apps.ts src/genos/GenOS.tsx src/genos/shell/HomeScreen.tsx src/genos/shell/Switcher.tsx src/genos/providers.ts src/genos/tools src/genos/workflows.ts` +> Plans 002 and 003 are expected to change these paths. Confirm their done +> criteria and adapt only names, not architecture. + +## Status + +- **Priority**: P1 +- **Effort**: M (a day-ish) +- **Risk**: MED +- **Depends on**: `plans/002-provider-favicons.md`, + `plans/003-firecrawl-runtime.md` +- **Category**: direction +- **Planned at**: commit `9867af9`, 2026-08-16, with uncommitted UI changes + +## Why this matters + +Users need a discoverable way to invoke Firecrawl's base operations and its +specialized workflows from the existing "Ask for anything" field. A typed command +catalog and filtered slash menu provide that surface while keeping ordinary +natural-language routing unchanged. Each command must launch a provider-branded +session with its own onboarding inputs and workflow contract, not merely prepend +a decorative command name to a generic prompt. + +## Current state + +- `src/genos/shell/HomeScreen.tsx:318-349` owns a plain `ask` string and submits + it unchanged through `onCommand`. +- `src/genos/shell/HomeScreen.tsx:438-479` renders the only ask input; there is no + command menu or selected-command state. +- `src/genos/GenOS.tsx:490-536` routes OS intents, known apps, active-app actions, + and generic summoned apps. It does not parse a leading slash. +- `src/genos/apps.ts:114-121` can create a generic summoned app but has no + workflow command helper. +- Plan 002 adds provider metadata and Firecrawl favicon rendering. +- Plan 003 adds `workflowId`, provider availability, Firecrawl BYOK, and the real + execution runtime. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Typecheck | `npm run typecheck` | exit 0 | +| Command tests | `npm test -- --runInBand --no-watchman __tests__/commands.test.tsx` | all pass | +| Full tests | `npm test -- --runInBand --no-watchman` | all pass | + +## Scope + +**In scope**: + +- `src/genos/commands.ts` (create) +- `src/genos/shell/CommandMenu.tsx` (create) +- `src/genos/shell/WorkflowSetup.tsx` (create) +- `src/genos/shell/HomeScreen.tsx` +- `src/genos/GenOS.tsx` +- `src/genos/apps.ts` +- Provider/workflow registries created by Plans 002 and 003 +- `__tests__/commands.test.tsx` (create) +- `__tests__/store.test.ts` for launched workflow metadata +- `README.md` + +**Out of scope**: + +- Reimplementing Firecrawl network clients +- Importing local `SKILL.md` files at runtime +- File/CSV upload, download/export, CRM writeback, or outreach sending +- Executing disabled commands without a Firecrawl key +- Adding command palettes for unrelated providers +- Changing ordinary OS intent routing + +## Git workflow + +- Branch: `advisor/004-firecrawl-slash-commands` +- Match imperative sentence-case commit messages. +- Keep catalog/parser, UI, and routing/tests as separable logical commits. +- Do not push or open a PR unless instructed. + +## Steps + +### Step 1: Define the command contract and catalog + +Create `src/genos/commands.ts` with a typed immutable definition such as: + +```ts +interface SlashCommandDef { + id: string; // exact slash name without '/' + title: string; + description: string; + providerId: "firecrawl"; + workflowId: string; + inputHint: string; + availability: "enabled" | "needs-key" | "unavailable"; +} +``` + +Seed the catalog with the complete currently installed Firecrawl workflow set: + +- `/firecrawl` +- `/firecrawl-company-directories` +- `/firecrawl-competitive-intel` +- `/firecrawl-dashboard-reporting` +- `/firecrawl-deep-research` +- `/firecrawl-demo-walkthrough` +- `/firecrawl-knowledge-base` +- `/firecrawl-knowledge-ingest` +- `/firecrawl-lead-gen` +- `/firecrawl-lead-research` +- `/firecrawl-market-research` +- `/firecrawl-qa` +- `/firecrawl-research-papers` +- `/firecrawl-seo-audit` +- `/firecrawl-shop` +- `/firecrawl-website-design-clone` +- `/firecrawl-workflows` + +Only mark a command `enabled` when Plan 003 has a corresponding trusted workflow +contract and backend path. Completion requires every command above to be +runnable; `unavailable` exists for a runtime/config failure, not as a placeholder +for unfinished implementation. Future workflows should remain a data change plus +contract/tests, not another HomeScreen rewrite. + +`/firecrawl` is the base router: known URL -> scrape; search-like query -> search; +complex autonomous structured task -> offer Agent with an explicit cost/depth +choice. It must not guess a costly Agent mode silently. + +**Verify**: catalog unit tests assert unique IDs, valid provider/workflow IDs, +stable ordering, and no enabled command without runtime support. + +### Step 2: Add pure parsing and filtering + +Export pure helpers: + +- `parseSlashCommand(text)`: recognize a command only at the beginning, split + exact command ID from the remaining argument, and report unknown commands; +- `filterSlashCommands(text)`: while the input begins with `/`, match ID, title, + and keywords case-insensitively; +- `commandToApp(command, argument)`: create an `AppDef` with a stable unique app + ID, Firecrawl provider ID, workflow ID, a human-readable session name, and a + request that includes user arguments but not secrets. + +Do not use prefix matching for execution: `/firecrawl-lead` must not execute +`/firecrawl-lead-gen`. Preserve spaces and URLs in the argument. Cap user input +length consistently with the Firecrawl API. + +**Verify**: tests cover exact match, partial filtering, unknown command, empty +argument, URL with query string, mixed case, leading whitespace policy, and +command injection-like text after the argument. + +### Step 3: Build the inline command menu + +Create `src/genos/shell/CommandMenu.tsx` and render it adjacent to the existing +ask field when the current input begins with `/`. + +Required behavior: + +- results filter live as the user types; +- every row shows `ProviderIcon(providerId)`, exact slash ID, title/description, + and enabled/needs-key/unavailable state; +- tapping an enabled row selects it and leaves focus in the argument input; +- tapping a needs-key row opens the Firecrawl provider key gate from Plan 003; +- unavailable rows cannot execute and explain their state accessibly; +- keyboard/web controls support up/down, Enter, and Escape; +- native controls support tap, dismissal, and screen-reader labels; +- the menu stays within safe area/keyboard bounds and uses the existing glass + visual language plus the global Inter typography; +- empty filtered results show a small non-actionable state, not a generic LLM + request. + +Do not replace the current rotating suggestion rows. Hide or visually de-emphasize +them only while the slash menu is open to avoid competing tap targets. + +**Verify**: render tests cover closed, filtered, selected, disabled, key-required, +empty, and accessibility states. + +### Step 4: Route commands before generic natural language + +In `GenOS.routeCommand`, parse slash commands before known-app and generic summon +routing, but after direct OS navigation commands if the existing ordering needs +to remain authoritative. + +For a recognized enabled command: + +- require a present Firecrawl key or show the provider key gate; +- validate the required inputs from the command contract; +- launch the provider/workflow `AppDef` through the existing `launch` path so + session, minimize, resume, switcher, retry, and close behavior remains shared; +- never execute a command inside an unrelated active app as a child of that app; + a leading slash starts/switches to a provider session; +- keep the full user argument as the factual task input, while the compact + trusted workflow contract comes from `workflowId`. + +Unknown slash commands must produce a deterministic menu/error state and must +not be sent to Cerebras as a generic app request. + +**Verify**: route tests assert known command launch metadata, provider/workflow +identity, missing-key behavior, unknown command behavior, and unchanged natural +language/OS routing. + +### Step 5: Add deterministic onboarding for workflow-specific inputs + +Each enabled workflow must define required inputs and a deterministic first +screen when they are missing: + +- company directories: directory URL/name, optional filters, result cap, output + view; +- deep research: topic and mandatory quick/thorough/exhaustive runtime/depth; +- lead gen: target, source/auth note, lead cap, output view; +- lead research: company or URL, optional person, meeting context; +- base Firecrawl: URL or query and explicit operation choice when ambiguous. + +Create one shell-owned `WorkflowSetup` form driven by typed field definitions in +the workflow registry. Support text, URL, bounded number, select, and checkbox +fields; do not hand-build 17 forms or rely on the LLM to invent required inputs. +Validate locally, show the selected maximum credit cap, and only then create the +workflow `AppDef`. Do not start a paid Agent job until required fields and +budget/depth confirmation are present. Do not ask more than the contract +requires. + +The in-app deliverable is an app screen, so adapt file-oriented skill outputs as: + +- a concise summary/current progress screen; +- structured tables/lists and data-gap sections; +- source links; +- follow-up actions for details or next pages. + +Do not claim that CSV/JSON was exported until export exists. + +**Verify**: tests assert incomplete commands produce onboarding without a +Firecrawl POST, and complete commands produce exactly one job/search/scrape. + +### Step 6: Verify the complete command/session flow + +Run full automated tests, then manually exercise: + +1. type `/fir` and select `/firecrawl`; +2. enter a public URL and run a low-cost scrape; +3. minimize the result and verify Firecrawl favicon on home; +4. open switcher and verify provider metadata; +5. launch `/firecrawl-lead-research ` with a low cap; +6. cancel an in-flight task and verify no duplicate rerun; +7. remove/reject the key and verify ordinary AppLess prompts still work; +8. try an unknown command and simulate an unavailable-provider state. + +Update README with command syntax, enabled catalog, key/cost warning, and the +distinction between in-app structured views and future CSV/export support. + +**Verify**: `npm run typecheck` and +`npm test -- --runInBand --no-watchman` both exit 0. + +## Test plan + +- `__tests__/commands.test.tsx`: registry integrity, parser/filter, menu states, + keyboard/tap selection, and accessibility. +- `__tests__/store.test.ts`: launched app has provider/workflow metadata and + children inherit it. +- Mocked integration test: command -> onboarding -> one tool call -> formatted + result screen metadata. +- Existing render and tool suites remain green. +- Manual low-credit device checks listed in Step 6. + +## Done criteria + +- [ ] Typing `/` opens a filtered provider command menu. +- [ ] The catalog contains every listed Firecrawl workflow and all are runnable + when a valid key/provider connection is present. +- [ ] Commands are enabled only when their runtime contract works. +- [ ] No paid call starts before required inputs and budget/depth selection. +- [ ] Unknown commands never fall through to generic Cerebras generation. +- [ ] Firecrawl sessions retain provider favicon and workflow identity through + home and switcher flows. +- [ ] Missing/rejected Firecrawl credentials do not block ordinary app use. +- [ ] Typecheck and complete no-Watchman tests pass. +- [ ] README documents syntax, enabled status, costs, and limitations. +- [ ] `plans/README.md` status is updated. + +## STOP conditions + +Stop and report if: + +- Plans 002 or 003 are not complete; +- a listed enabled command has no trusted workflow contract or explicit credit + policy; +- command invocation would require reading local Codex skill files at runtime; +- the menu cannot remain usable above the native keyboard/safe area; +- the requested deliverable requires file export or authenticated browser + sessions not authorized for this scope. + +## Maintenance notes + +- The catalog should be generated from typed data, never duplicated across menu, + parser, and router. +- Reviewers should scrutinize exact-match execution, disabled states, key gates, + cost confirmation, accessibility, and command behavior inside an active app. +- When additional Firecrawl workflows are added, add their compact trusted + contract and runtime tests before exposing them in the catalog. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..041c0ac --- /dev/null +++ b/plans/README.md @@ -0,0 +1,69 @@ +# Implementation Plans + +Generated by the improve skill on 2026-08-16. These plans use the current +working tree as the baseline, including the uncommitted Inter typography work. +Execute in the order below unless the dependency notes say otherwise. Each +executor must read its plan fully, honor its STOP conditions, and update its row +when done. + +## Execution order and status + +| Plan | Title | Priority | Effort | Depends on | Status | +|------|-------|----------|--------|------------|--------| +| 001 | Finish and verify Linear-style global typography | P1 | S | - | TODO | +| 002 | Add Raycast-compatible provider favicons | P1 | S | - | TODO | +| 003 | Add the Firecrawl provider runtime | P1 | L | - | TODO | +| 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | TODO | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | +REJECTED (with one-line rationale) + +## Dependency notes + +- Plans 001, 002, and 003 can be implemented independently. +- Plan 004 depends on 002 because command rows and resulting sessions use the + provider favicon metadata. +- Plan 004 depends on 003 because a command must not be discoverable as an + enabled action until the corresponding Firecrawl runtime can execute it. + +## Baseline verification + +- `npm run typecheck` passes at commit `9867af9` plus the current uncommitted + working-tree changes. +- `npm test -- --runInBand --no-watchman` passes: 4 suites, 22 tests. +- Plain `npm test -- --runInBand` fails in the current managed environment + because Watchman cannot change permissions under the user's state directory; + this is an environment issue, not a repository test failure. Use + `--no-watchman` in every plan. + +## Architectural decisions captured by these plans + +- Linear's current public CSS uses `Inter Variable` for regular text and + `Berkeley Mono` for monospace. AppLess has no code/monospace text role today, + so the global application family is Inter; do not apply Berkeley Mono to + ordinary UI copy. +- Raycast's `getFavicon` cannot run inside Expo because it returns Raycast API + image types and depends on `@raycast/api`. Reproduce its provider URL behavior + in a small React Native resolver instead of adding Raycast packages. +- The named `firecrawl-*` skills are local agent instruction packages, not code + that can be bundled into an Expo application. Represent them as a typed command + catalog with distilled workflow contracts, backed by Firecrawl's public API. +- Firecrawl Agent jobs are asynchronous and credit-bearing. Never expose them as + a one-shot synchronous fetch with an unlimited/default credit budget. + +## Findings considered and rejected + +- Add `@raycast/utils` to the Expo app: rejected because it has an + `@raycast/api` peer dependency and its image return type is Raycast-runtime + specific. +- Download Linear's hosted font files from `static.linear.app`: rejected because + the repository already uses the openly licensed Expo Inter package, which is + safer to redistribute and supports consistent named weights on iOS and + Android. +- Copy local `SKILL.md` files into the app at runtime: rejected because those + user-machine paths are unavailable in packaged builds and would couple the app + to a Codex installation. +- Put a shared Firecrawl key in `EXPO_PUBLIC_FIRECRAWL_API_KEY`: rejected for + production because Expo public variables are bundled into the client. The app + remains BYOK and stores the user's key on-device. + diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 6e6b29e..e675d69 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -16,7 +16,6 @@ import { Platform, Pressable, ScrollView, - Text, View, useWindowDimensions, } from "react-native"; @@ -38,6 +37,7 @@ import { setActiveScreen, } from "./store"; import { useCds } from "./theme"; +import { Text } from "./typography"; import { AppsIcon, LucideIcon } from "./ui/icons"; /** Shape of the ActionEvent the Renderer dispatches (subset we use). */ diff --git a/src/genos/shell/HomeScreen.tsx b/src/genos/shell/HomeScreen.tsx index 26e7207..99ef03c 100644 --- a/src/genos/shell/HomeScreen.tsx +++ b/src/genos/shell/HomeScreen.tsx @@ -31,13 +31,12 @@ import { Platform, Pressable, ScrollView, - Text, - TextInput, View, } from "react-native"; import { SvgXml } from "react-native-svg"; import type { Suggestion } from "../apps"; import { SUGGESTIONS } from "../apps"; +import { Text, TextInput, linearType } from "../typography"; import { APPLESS_LOGO_XML } from "./applessLogo"; import type { RunningApp } from "./Switcher"; @@ -117,7 +116,7 @@ function SuggestionRow({ style={{ flexDirection: "row", alignItems: "center", gap: 10, opacity: fade }} > - + {shown} @@ -387,10 +386,7 @@ export const HomeScreen = React.memo(function HomeScreen({ style={{ marginTop: 4, textAlign: "center", - fontSize: 24, - fontWeight: "400", - letterSpacing: -0.5, - lineHeight: 30, + ...linearType.cardTitle, color: "rgba(255,255,255,0.5)", textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 18, diff --git a/src/genos/shell/KeyGate.tsx b/src/genos/shell/KeyGate.tsx index 4a07c34..751969d 100644 --- a/src/genos/shell/KeyGate.tsx +++ b/src/genos/shell/KeyGate.tsx @@ -5,9 +5,10 @@ * API rejects the stored key. */ import React, { useState } from "react"; -import { Linking, Pressable, Text, TextInput, View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; import { cerebrasKey, type KeyStatus } from "../../config"; import { useCds } from "../theme"; +import { Text, TextInput, linearType } from "../typography"; const ACCENT = "#5e5ce6"; @@ -39,7 +40,7 @@ export function KeyGate({ status }: { status: KeyStatus }) { gap: 14, }} > - + AppLess diff --git a/src/genos/shell/Switcher.tsx b/src/genos/shell/Switcher.tsx index f972438..e3137e5 100644 --- a/src/genos/shell/Switcher.tsx +++ b/src/genos/shell/Switcher.tsx @@ -1,10 +1,11 @@ import { Renderer } from "@openuidev/react-lang"; import { LinearGradient } from "expo-linear-gradient"; import React from "react"; -import { Pressable, ScrollView, Text, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { genosLibrary } from "../library"; import { cleanLang, screenStore } from "../store"; import { useCds } from "../theme"; +import { Text } from "../typography"; export interface RunningApp { id: string; diff --git a/src/genos/typography.tsx b/src/genos/typography.tsx new file mode 100644 index 0000000..62ccea5 --- /dev/null +++ b/src/genos/typography.tsx @@ -0,0 +1,131 @@ +import React, { createContext, useContext } from "react"; +import { + Platform, + StyleSheet, + Text as NativeText, + TextInput as NativeTextInput, + type TextInputProps, + type TextProps, + type TextStyle, +} from "react-native"; + +const FONT_FAMILIES = { + light: "Inter_300Light", + regular: "Inter_400Regular", + medium: "Inter_500Medium", + semibold: "Inter_600SemiBold", + bold: "Inter_700Bold", + extrabold: "Inter_800ExtraBold", +} as const; + +const SYSTEM_FONT = Platform.select({ + ios: "System", + android: "sans-serif", + default: "system-ui", +}); + +const TypographyReadyContext = createContext(false); + +export function TypographyProvider({ + loaded, + children, +}: { + loaded: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function familyForStyle(style: TextProps["style"] | TextInputProps["style"], loaded: boolean) { + const flattened = StyleSheet.flatten(style) as TextStyle | undefined; + if (flattened?.fontFamily) return flattened.fontFamily; + if (!loaded) return SYSTEM_FONT; + + const numericWeight = Number.parseInt(String(flattened?.fontWeight ?? "400"), 10); + if (numericWeight >= 800) return FONT_FAMILIES.extrabold; + if (numericWeight >= 700) return FONT_FAMILIES.bold; + if (numericWeight >= 600) return FONT_FAMILIES.semibold; + if (numericWeight >= 500) return FONT_FAMILIES.medium; + if (numericWeight <= 300) return FONT_FAMILIES.light; + return FONT_FAMILIES.regular; +} + +export function Text({ style, ...props }: TextProps) { + const loaded = useContext(TypographyReadyContext); + const fontFamily = familyForStyle(style, loaded); + return ; +} + +export function TextInput({ style, ...props }: TextInputProps) { + const loaded = useContext(TypographyReadyContext); + const fontFamily = familyForStyle(style, loaded); + return ; +} + +/** Linear-inspired hierarchy using Inter, its closest public substitute. */ +export const linearType = { + displayLarge: { + fontSize: 56, + lineHeight: 62, + fontWeight: "600", + letterSpacing: -1.8, + }, + headline: { + fontSize: 28, + lineHeight: 34, + fontWeight: "600", + letterSpacing: -0.6, + }, + cardTitle: { + fontSize: 22, + lineHeight: 28, + fontWeight: "500", + letterSpacing: -0.4, + }, + subhead: { + fontSize: 20, + lineHeight: 28, + fontWeight: "400", + letterSpacing: -0.2, + }, + bodyLarge: { + fontSize: 18, + lineHeight: 27, + fontWeight: "400", + letterSpacing: -0.1, + }, + body: { + fontSize: 16, + lineHeight: 24, + fontWeight: "400", + letterSpacing: -0.05, + }, + bodySmall: { + fontSize: 14, + lineHeight: 21, + fontWeight: "400", + letterSpacing: 0, + }, + caption: { + fontSize: 12, + lineHeight: 17, + fontWeight: "400", + letterSpacing: 0, + }, + button: { + fontSize: 14, + lineHeight: 17, + fontWeight: "500", + letterSpacing: 0, + }, + eyebrow: { + fontSize: 13, + lineHeight: 17, + fontWeight: "500", + letterSpacing: 0.4, + }, +} satisfies Record; diff --git a/src/genos/ui/cupertino/components.tsx b/src/genos/ui/cupertino/components.tsx index 19f3cfd..452014d 100644 --- a/src/genos/ui/cupertino/components.tsx +++ b/src/genos/ui/cupertino/components.tsx @@ -5,7 +5,8 @@ import { useTriggerAction } from "@openuidev/react-lang"; import { LinearGradient } from "expo-linear-gradient"; import React, { useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Pressable, ScrollView, StyleSheet, View } from "react-native"; +import { Text, linearType } from "../../typography"; import { useTap } from "../shared/actions"; import { createImg } from "../shared/media"; import type { @@ -65,9 +66,7 @@ export const CardHeader: Renderer = ({ props }) => { = ({ props }) => { )} @@ -91,15 +87,12 @@ export const CardHeader: Renderer = ({ props }) => { }; const TEXT_STYLES = { - small: { fontSize: 12.5, lineHeight: 18 }, - default: { fontSize: 15, lineHeight: 22 }, - large: { fontSize: 17, lineHeight: 24 }, - "small-heavy": { fontSize: 13, lineHeight: 19, fontWeight: "600" as const }, + small: linearType.caption, + default: linearType.body, + large: linearType.bodyLarge, + "small-heavy": linearType.button, "large-heavy": { - fontSize: 20, - lineHeight: 26, - fontWeight: "700" as const, - letterSpacing: -0.3, + ...linearType.cardTitle, marginBottom: -6, }, }; diff --git a/src/genos/ui/cupertino/forms.tsx b/src/genos/ui/cupertino/forms.tsx index 7465c27..e5383de 100644 --- a/src/genos/ui/cupertino/forms.tsx +++ b/src/genos/ui/cupertino/forms.tsx @@ -7,7 +7,8 @@ import { FormNameContext, useFormName, useTriggerAction } from "@openuidev/react import type { ActionPlan } from "@openuidev/react-lang"; import RNSlider from "@react-native-community/slider"; import React, { useState } from "react"; -import { Modal, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { Text, TextInput } from "../../typography"; import { readSelectItems, textInputBehaviorProps, useFieldState } from "../shared/forms"; import type { ButtonProps, diff --git a/src/genos/ui/material/components.tsx b/src/genos/ui/material/components.tsx index 3b7343b..b4536e3 100644 --- a/src/genos/ui/material/components.tsx +++ b/src/genos/ui/material/components.tsx @@ -5,7 +5,8 @@ import { useTriggerAction } from "@openuidev/react-lang"; import { LinearGradient } from "expo-linear-gradient"; import React, { useState } from "react"; -import { Pressable, ScrollView, Switch, Text, View } from "react-native"; +import { Pressable, ScrollView, Switch, View } from "react-native"; +import { Text, linearType } from "../../typography"; import { useTap } from "../shared/actions"; import { createImg } from "../shared/media"; import type { @@ -63,16 +64,14 @@ export const CardHeader: Renderer = ({ props }) => { {props.subtitle} )} - + {props.title} @@ -80,14 +79,12 @@ export const CardHeader: Renderer = ({ props }) => { }; const TEXT_STYLES = { - small: { fontSize: 12.5, lineHeight: 18 }, - default: { fontSize: 15, lineHeight: 22 }, - large: { fontSize: 17, lineHeight: 24 }, - "small-heavy": { fontSize: 13, lineHeight: 19, fontWeight: "500" as const }, + small: linearType.caption, + default: linearType.body, + large: linearType.bodyLarge, + "small-heavy": linearType.button, "large-heavy": { - fontSize: 20, - lineHeight: 26, - fontWeight: "500" as const, + ...linearType.cardTitle, marginBottom: -6, }, }; diff --git a/src/genos/ui/material/forms.tsx b/src/genos/ui/material/forms.tsx index 540a304..be6fd89 100644 --- a/src/genos/ui/material/forms.tsx +++ b/src/genos/ui/material/forms.tsx @@ -3,7 +3,8 @@ import { FormNameContext, useFormName, useTriggerAction } from "@openuidev/react import type { ActionPlan } from "@openuidev/react-lang"; import RNSlider from "@react-native-community/slider"; import React, { useState } from "react"; -import { Modal, Pressable, Text, TextInput, View } from "react-native"; +import { Modal, Pressable, View } from "react-native"; +import { Text, TextInput } from "../../typography"; import { readSelectItems, textInputBehaviorProps, useFieldState } from "../shared/forms"; import type { ButtonProps, diff --git a/src/genos/ui/shared/charts.tsx b/src/genos/ui/shared/charts.tsx index b720d40..31252e9 100644 --- a/src/genos/ui/shared/charts.tsx +++ b/src/genos/ui/shared/charts.tsx @@ -4,7 +4,8 @@ * its own colors. Signatures/schemas live in ../contract.tsx. */ import React, { useState } from "react"; -import { Text as RNText, View } from "react-native"; +import { View } from "react-native"; +import { Text as RNText } from "../../typography"; import Svg, { Circle, G, Path, Rect, Text as SvgText } from "react-native-svg"; import type { CartesianChartProps, GenosRenderers, PieChartProps, Renderer } from "../contract"; From 724b2ac6185879a2f2fa4173691cfa691521daea Mon Sep 17 00:00:00 2001 From: eaczydot Date: Sun, 16 Aug 2026 21:04:16 -0400 Subject: [PATCH 2/5] Verify Linear typography behavior --- __tests__/typography.test.tsx | 106 ++++++++++++++++++++++++++++++++++ plans/README.md | 3 +- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 __tests__/typography.test.tsx diff --git a/__tests__/typography.test.tsx b/__tests__/typography.test.tsx new file mode 100644 index 0000000..ea12237 --- /dev/null +++ b/__tests__/typography.test.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { + Platform, + StyleSheet, + Text as NativeText, + TextInput as NativeTextInput, + type TextStyle, +} from "react-native"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + Text, + TextInput, + TypographyProvider, +} from "../src/genos/typography"; + +function renderText(style?: TextStyle | TextStyle[], loaded = true) { + let tree!: ReactTestRenderer; + act(() => { + tree = create( + + Typography + , + ); + }); + return tree; +} + +function renderedStyle( + tree: ReactTestRenderer, + type: typeof NativeText | typeof NativeTextInput, +) { + return StyleSheet.flatten(tree.root.findByType(type).props.style) as TextStyle; +} + +describe("global typography wrappers", () => { + it("uses the regular Inter family when no weight is requested", () => { + const tree = renderText(); + expect(renderedStyle(tree, NativeText)).toMatchObject({ + fontFamily: "Inter_400Regular", + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); + + it.each([ + ["300", "Inter_300Light"], + ["500", "Inter_500Medium"], + ["600", "Inter_600SemiBold"], + ["700", "Inter_700Bold"], + ["800", "Inter_800ExtraBold"], + ] as const)("maps weight %s to %s", (fontWeight, fontFamily) => { + const tree = renderText({ fontWeight }); + expect(renderedStyle(tree, NativeText)).toMatchObject({ + fontFamily, + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); + + it("flattens style arrays before selecting the font family", () => { + const tree = renderText([{ fontWeight: "300" }, { fontWeight: "700" }]); + expect(renderedStyle(tree, NativeText)).toMatchObject({ + fontFamily: "Inter_700Bold", + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); + + it("preserves an explicit font family", () => { + const tree = renderText({ fontFamily: "CustomFamily", fontWeight: "700" }); + expect(renderedStyle(tree, NativeText)).toMatchObject({ + fontFamily: "CustomFamily", + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); + + it("uses the platform system family before Inter is loaded", () => { + const tree = renderText({ fontWeight: "700" }, false); + expect(renderedStyle(tree, NativeText)).toMatchObject({ + fontFamily: Platform.select({ + ios: "System", + android: "sans-serif", + default: "system-ui", + }), + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); + + it("applies the same family and native weight reset to text inputs", () => { + let tree!: ReactTestRenderer; + act(() => { + tree = create( + + + , + ); + }); + expect(renderedStyle(tree, NativeTextInput)).toMatchObject({ + fontFamily: "Inter_600SemiBold", + fontWeight: "normal", + }); + act(() => tree.unmount()); + }); +}); diff --git a/plans/README.md b/plans/README.md index 041c0ac..c228221 100644 --- a/plans/README.md +++ b/plans/README.md @@ -10,7 +10,7 @@ when done. | Plan | Title | Priority | Effort | Depends on | Status | |------|-------|----------|--------|------------|--------| -| 001 | Finish and verify Linear-style global typography | P1 | S | - | TODO | +| 001 | Finish and verify Linear-style global typography | P1 | S | - | DONE | | 002 | Add Raycast-compatible provider favicons | P1 | S | - | TODO | | 003 | Add the Firecrawl provider runtime | P1 | L | - | TODO | | 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | TODO | @@ -66,4 +66,3 @@ REJECTED (with one-line rationale) - Put a shared Firecrawl key in `EXPO_PUBLIC_FIRECRAWL_API_KEY`: rejected for production because Expo public variables are bundled into the client. The app remains BYOK and stores the user's key on-device. - From 7a873b81b4d69778c4188a30c72924b8950a5c57 Mon Sep 17 00:00:00 2001 From: eaczydot Date: Sun, 16 Aug 2026 21:05:09 -0400 Subject: [PATCH 3/5] Add provider favicon fallbacks --- __tests__/providers.test.tsx | 69 ++++++++++++++++++++++++++++++++++ plans/README.md | 2 +- src/genos/GenOS.tsx | 10 ++++- src/genos/apps.ts | 1 + src/genos/providers.ts | 38 +++++++++++++++++++ src/genos/shell/HomeScreen.tsx | 7 +++- src/genos/shell/Switcher.tsx | 40 ++++++++++++-------- src/genos/ui/ProviderIcon.tsx | 56 +++++++++++++++++++++++++++ 8 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 __tests__/providers.test.tsx create mode 100644 src/genos/providers.ts create mode 100644 src/genos/ui/ProviderIcon.tsx diff --git a/__tests__/providers.test.tsx b/__tests__/providers.test.tsx new file mode 100644 index 0000000..6f4e6f3 --- /dev/null +++ b/__tests__/providers.test.tsx @@ -0,0 +1,69 @@ +import { Globe } from "phosphor-react-native"; +import React from "react"; +import { Image } from "react-native"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { APPS } from "../src/genos/apps"; +import { getProvider, raycastFaviconUrl } from "../src/genos/providers"; +import { ProviderIcon } from "../src/genos/ui/ProviderIcon"; + +describe("provider registry and favicon resolver", () => { + it("normalizes a bare domain and full URL to the hostname", () => { + expect(raycastFaviconUrl("firecrawl.dev")).toBe( + "https://api.ray.so/favicon?url=firecrawl.dev&size=64", + ); + expect(raycastFaviconUrl("https://www.firecrawl.dev/docs?q=icons#usage")).toBe( + "https://api.ray.so/favicon?url=www.firecrawl.dev&size=64", + ); + }); + + it("removes paths and queries from bare domains", () => { + expect(raycastFaviconUrl("firecrawl.dev/docs?source=app", 32)).toBe( + "https://api.ray.so/favicon?url=firecrawl.dev&size=32", + ); + }); + + it("defaults and clamps favicon sizes", () => { + expect(raycastFaviconUrl("firecrawl.dev")).toContain("&size=64"); + expect(raycastFaviconUrl("firecrawl.dev", 1)).toContain("&size=16"); + expect(raycastFaviconUrl("firecrawl.dev", 999)).toContain("&size=256"); + }); + + it("returns null for malformed or unsupported input", () => { + expect(raycastFaviconUrl("not a url")).toBeNull(); + expect(raycastFaviconUrl("http://firecrawl.dev")).toBeNull(); + expect(raycastFaviconUrl("")).toBeNull(); + }); + + it("looks up known providers without inventing unknown metadata", () => { + expect(getProvider("firecrawl")).toMatchObject({ + name: "Firecrawl", + domain: "firecrawl.dev", + }); + expect(getProvider("unknown")).toBeUndefined(); + expect(getProvider("toString")).toBeUndefined(); + }); + + it("keeps built-in app metadata provider-free", () => { + expect(APPS).not.toHaveLength(0); + expect(APPS.every((app) => app.providerId === undefined)).toBe(true); + }); +}); + +describe("ProviderIcon", () => { + it("shows a local fallback while loading and after an image error", () => { + let tree!: ReactTestRenderer; + act(() => { + tree = create(); + }); + + expect(tree.root.findAllByType(Globe)).toHaveLength(1); + const image = tree.root.findByType(Image); + expect(image.props.accessibilityLabel).toBe("Firecrawl provider"); + + act(() => image.props.onError()); + expect(tree.root.findAllByType(Image)).toHaveLength(0); + expect(tree.root.findAllByType(Globe)).toHaveLength(1); + + act(() => tree.unmount()); + }); +}); diff --git a/plans/README.md b/plans/README.md index c228221..702f159 100644 --- a/plans/README.md +++ b/plans/README.md @@ -11,7 +11,7 @@ when done. | Plan | Title | Priority | Effort | Depends on | Status | |------|-------|----------|--------|------------|--------| | 001 | Finish and verify Linear-style global typography | P1 | S | - | DONE | -| 002 | Add Raycast-compatible provider favicons | P1 | S | - | TODO | +| 002 | Add Raycast-compatible provider favicons | P1 | S | - | DONE | | 003 | Add the Firecrawl provider runtime | P1 | L | - | TODO | | 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | TODO | diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index e675d69..d4b4eb6 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -51,6 +51,7 @@ interface AppMeta { name: string; emoji: string; tile: [string, string]; + providerId?: string; } const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); @@ -302,7 +303,12 @@ export default function GenOS() { const launch = useCallback( (app: AppDef) => { setNavAnim("launch"); - rememberMeta(app.id, { name: app.name, emoji: app.emoji, tile: app.tile }); + rememberMeta(app.id, { + name: app.name, + emoji: app.emoji, + tile: app.tile, + providerId: app.providerId, + }); // openApp touches the screen store (which notifies subscribers), so it // must run here in the event handler - never inside a setState updater. if (!sessions[app.id]?.length) { @@ -378,6 +384,7 @@ export default function GenOS() { name: known?.name ?? capitalize(appId), emoji: known?.emoji ?? "✨", tile: known?.tile ?? DEFAULT_TILE, + providerId: known?.providerId, }); pushScreen(appId.toLowerCase(), id); activate(appId.toLowerCase()); @@ -546,6 +553,7 @@ export default function GenOS() { name: appMeta[id]?.name ?? capitalize(id), emoji: appMeta[id]?.emoji ?? "✨", tile: appMeta[id]?.tile ?? DEFAULT_TILE, + providerId: appMeta[id]?.providerId, })), [recentOrder, sessions, appMeta], ); diff --git a/src/genos/apps.ts b/src/genos/apps.ts index 6e0abc2..775f7f4 100644 --- a/src/genos/apps.ts +++ b/src/genos/apps.ts @@ -2,6 +2,7 @@ export interface AppDef { id: string; name: string; emoji: string; + providerId?: string; /** Gradient stops for the icon tile. */ tile: [string, string]; /** The request sent to the model to open the app's home screen. */ diff --git a/src/genos/providers.ts b/src/genos/providers.ts new file mode 100644 index 0000000..fba0ec9 --- /dev/null +++ b/src/genos/providers.ts @@ -0,0 +1,38 @@ +export interface ProviderDef { + id: string; + name: string; + domain: string; + homepage: string; + tile: [string, string]; + fallbackGlyph: string; +} + +const PROVIDERS: Record = { + firecrawl: { + id: "firecrawl", + name: "Firecrawl", + domain: "firecrawl.dev", + homepage: "https://firecrawl.dev", + tile: ["#fa5d3b", "#f59e0b"], + fallbackGlyph: "globe", + }, +}; + +export function getProvider(id: string): ProviderDef | undefined { + return Object.prototype.hasOwnProperty.call(PROVIDERS, id) ? PROVIDERS[id] : undefined; +} + +export function raycastFaviconUrl(domainOrUrl: string, size = 64): string | null { + try { + const input = domainOrUrl.trim(); + if (!input) return null; + + const url = new URL(input.includes("://") ? input : `https://${input}`); + if (url.protocol !== "https:" || !url.hostname) return null; + + const safeSize = Number.isFinite(size) ? Math.min(256, Math.max(16, Math.trunc(size))) : 64; + return `https://api.ray.so/favicon?url=${encodeURIComponent(url.hostname)}&size=${safeSize}`; + } catch { + return null; + } +} diff --git a/src/genos/shell/HomeScreen.tsx b/src/genos/shell/HomeScreen.tsx index 99ef03c..9fe01ee 100644 --- a/src/genos/shell/HomeScreen.tsx +++ b/src/genos/shell/HomeScreen.tsx @@ -37,6 +37,7 @@ import { SvgXml } from "react-native-svg"; import type { Suggestion } from "../apps"; import { SUGGESTIONS } from "../apps"; import { Text, TextInput, linearType } from "../typography"; +import { ProviderIcon } from "../ui/ProviderIcon"; import { APPLESS_LOGO_XML } from "./applessLogo"; import type { RunningApp } from "./Switcher"; @@ -250,7 +251,11 @@ function AppIcon({ }, ]} > - + {app.providerId ? ( + + ) : ( + + )} {editing && ( - - {app.emoji} - + {app.providerId ? ( + + ) : ( + + {app.emoji} + + )} ) : ( - {app.emoji} + {app.providerId ? ( + + ) : ( + {app.emoji} + )} )} diff --git a/src/genos/ui/ProviderIcon.tsx b/src/genos/ui/ProviderIcon.tsx new file mode 100644 index 0000000..c2a6737 --- /dev/null +++ b/src/genos/ui/ProviderIcon.tsx @@ -0,0 +1,56 @@ +import { Globe, Link } from "phosphor-react-native"; +import React, { useEffect, useState } from "react"; +import { Image, View } from "react-native"; +import { getProvider, raycastFaviconUrl } from "../providers"; + +export function ProviderIcon({ + providerId, + size, + cornerRadius = Math.round(size * 0.22), +}: { + providerId: string; + size: number; + cornerRadius?: number; +}) { + const provider = getProvider(providerId); + const source = provider ? raycastFaviconUrl(provider.domain, size) : null; + const [imageFailed, setImageFailed] = useState(false); + + useEffect(() => { + setImageFailed(false); + }, [providerId]); + + const FallbackIcon = provider?.fallbackGlyph === "globe" ? Globe : Link; + const label = provider ? `${provider.name} provider` : "Provider"; + + return ( + + + {source && !imageFailed && ( + setImageFailed(true)} + style={{ + position: "absolute", + width: size, + height: size, + borderRadius: cornerRadius, + }} + /> + )} + + ); +} From 6296ac26c5df33faf3de6207a1deec13befbe9c9 Mon Sep 17 00:00:00 2001 From: eaczydot Date: Sun, 16 Aug 2026 21:13:51 -0400 Subject: [PATCH 4/5] Add bounded Firecrawl provider runtime --- .env.example | 1 + README.md | 27 +- __tests__/firecrawl.test.ts | 279 ++++++++++++++++++ __tests__/store.test.ts | 63 +++- __tests__/tools.test.ts | 31 ++ plans/README.md | 2 +- src/genos/GenOS.tsx | 31 +- src/genos/apps.ts | 2 + src/genos/providers/firecrawl/key.ts | 96 +++++++ src/genos/shell/ProviderKeyGate.tsx | 96 +++++++ src/genos/store.ts | 41 ++- src/genos/stream.ts | 27 +- src/genos/tools/firecrawl.ts | 410 +++++++++++++++++++++++++++ src/genos/tools/index.ts | 71 +++++ src/genos/workflows.ts | 222 +++++++++++++++ 15 files changed, 1376 insertions(+), 23 deletions(-) create mode 100644 __tests__/firecrawl.test.ts create mode 100644 src/genos/providers/firecrawl/key.ts create mode 100644 src/genos/shell/ProviderKeyGate.tsx create mode 100644 src/genos/tools/firecrawl.ts create mode 100644 src/genos/tools/index.ts create mode 100644 src/genos/workflows.ts diff --git a/.env.example b/.env.example index ce24e20..27ccad9 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ EXPO_PUBLIC_CEREBRAS_API_KEY=csk-REPLACE_ME # Optional tools: # EXPO_PUBLIC_EXA_API_KEY= # web_search, else the model invents data # EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real photos, else LoremFlickr +# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; public/bundled, never a shared key # Optional model overrides (any OpenAI-compatible endpoint): # EXPO_PUBLIC_CEREBRAS_BASE_URL=https://api.cerebras.ai/v1 diff --git a/README.md b/README.md index 6118b55..0b6e630 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,18 @@ To skip the key prompt during development, or to turn on optional tools, drop a EXPO_PUBLIC_CEREBRAS_API_KEY=csk-... # skip the first-launch key screen # EXPO_PUBLIC_EXA_API_KEY= # enable the web_search tool (Exa) # EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real semantic photos (else LoremFlickr) +# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; bundled into the app # EXPO_PUBLIC_GENOS_MODEL=gemma-4-31b # EXPO_PUBLIC_CEREBRAS_BASE_URL= # any OpenAI-compatible endpoint ``` +Firecrawl is bring-your-own-key and optional. When a trusted Firecrawl workflow +needs it, AppLess asks for that provider's key and stores it separately on the +device (Keychain / Keystore, or localStorage on web). Missing or rejected +Firecrawl credentials do not affect ordinary AppLess, Cerebras generation, or +Exa search. Never ship a shared production credential through an +`EXPO_PUBLIC_*` variable: Expo inlines public variables into the client bundle. + > Expo SDK 54 is pinned to match the Expo Go build on the App Store and Play > Store. If your Expo Go reports a different SDK, run > `npx expo install expo@^ && npx expo install --fix`. @@ -105,6 +113,22 @@ places), the model calls a `web_search` tool ([Exa](https://exa.ai)) mid-stream and composes the screen from the results, sources cited. Images are referenced as semantic queries the app resolves on-device. +The optional Firecrawl runtime adds bounded v2 REST primitives for search, +single-page scrape, and asynchronous Agent jobs. Agent runs always use a +workflow-owned schema, an explicit central credit ceiling, a depth-specific +timeout, status polling, and cooperative cancellation. Firecrawl can consume +paid credits, and cancellation may accrue a small amount while an in-flight +step reaches a stopping point. Results are capped before returning to the model +while source URLs and recoverable Agent job IDs are retained. + +Current limitations: the specialized Firecrawl slash-command catalog is not +included yet; authenticated dashboards work only inside an already authorized +session boundary; login walls, CAPTCHAs, robots restrictions, paywalls, CSV +uploads, CRM writes, outreach, purchases, and destructive submissions are not +supported. Firecrawl Agent is a Research Preview API, so its isolated protocol +adapter should be rechecked against the official v2 documentation before +release. + ## Packages AppLess is a thin, native shell over the OpenUI runtime. The heavy lifting is @@ -124,7 +148,8 @@ the OpenUI stack: | [`@react-native-community/slider`](https://github.com/callstack/react-native-slider) | Native slider input for forms. | Screens are generated by [Cerebras](https://cloud.cerebras.ai); live search is -powered by [Exa](https://exa.ai). Both are bring-your-own-key. +powered by [Exa](https://exa.ai), with optional bounded research through +[Firecrawl](https://firecrawl.dev). Every provider is bring-your-own-key. ## Design systems diff --git a/__tests__/firecrawl.test.ts b/__tests__/firecrawl.test.ts new file mode 100644 index 0000000..6f95ee8 --- /dev/null +++ b/__tests__/firecrawl.test.ts @@ -0,0 +1,279 @@ +jest.mock("expo-secure-store", () => ({ + getItemAsync: jest.fn(async () => null), + setItemAsync: jest.fn(async () => {}), + deleteItemAsync: jest.fn(async () => {}), +})); +jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); + +import { fetch as expoFetch } from "expo/fetch"; +import React from "react"; +import renderer, { act } from "react-test-renderer"; +import { cerebrasKey } from "../src/config"; +import { ProviderKeyGate } from "../src/genos/shell/ProviderKeyGate"; +import { + cancelFirecrawlAgent, + firecrawlAgent, + firecrawlScrape, + firecrawlSearch, + getFirecrawlAgentStatus, + pollFirecrawlAgent, +} from "../src/genos/tools/firecrawl"; +import { + FirecrawlKeyStore, + firecrawlKey, +} from "../src/genos/providers/firecrawl/key"; +import { + agentPolicy, + FIRECRAWL_WORKFLOW_IDS, + FIRECRAWL_WORKFLOWS, + trustedAgentSchema, +} from "../src/genos/workflows"; + +const mockedFetch = expoFetch as jest.MockedFunction; + +function response(status: number, body: unknown) { + return { + status, + ok: status >= 200 && status < 300, + json: jest.fn(async () => body), + text: jest.fn(async () => JSON.stringify(body)), + } as unknown as Response; +} + +describe("Firecrawl BYOK store", () => { + it("hydrates, sets, and rejects only the current key", async () => { + const storage = { + read: jest.fn(async () => "fc-stored"), + write: jest.fn(async (_value: string | null) => {}), + }; + const store = new FirecrawlKeyStore(storage, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(store.get()).toBe("fc-stored"); + expect(store.getStatus()).toBe("present"); + + await store.set("fc-first"); + store.markRejected("fc-stale"); + expect(store.get()).toBe("fc-first"); + await store.set("fc-replaced"); + store.markRejected("fc-first"); + expect(store.get()).toBe("fc-replaced"); + store.markRejected("fc-replaced"); + expect(store.get()).toBeNull(); + expect(store.getStatus()).toBe("rejected"); + expect(storage.write).toHaveBeenLastCalledWith(null); + }); + + it("dismisses the provider gate without changing Cerebras credentials", async () => { + const dismiss = jest.fn(); + const before = cerebrasKey.get(); + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + React.createElement(ProviderKeyGate, { + status: "missing", + onDismiss: dismiss, + onConnected: jest.fn(), + }), + ); + }); + await act(async () => { + tree.root.findByProps({ accessibilityLabel: "Dismiss Firecrawl key prompt" }).props.onPress(); + }); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(cerebrasKey.get()).toBe(before); + await act(async () => tree.unmount()); + }); +}); + +describe("trusted Firecrawl workflow contracts", () => { + it.each(FIRECRAWL_WORKFLOW_IDS)("defines bounded policy and schema for %s", (id) => { + const contract = FIRECRAWL_WORKFLOWS[id]; + const policy = agentPolicy(id, "quick"); + const schema = trustedAgentSchema(id); + expect(contract.instructions.length).toBeGreaterThan(30); + expect(contract.resultFields.length).toBeGreaterThan(0); + expect(policy?.maxCredits).toBeGreaterThan(0); + expect(policy?.timeoutMs).toBeGreaterThan(0); + expect(schema).toMatchObject({ type: "object", additionalProperties: false }); + expect(Object.values((schema?.properties ?? {}) as Record)).not.toContainEqual({}); + }); + + it("keeps deep-research budgets centralized by depth", () => { + expect(agentPolicy("firecrawl-deep-research", "quick")?.maxCredits).toBe(100); + expect(agentPolicy("firecrawl-deep-research", "thorough")?.maxCredits).toBe(300); + expect(agentPolicy("firecrawl-deep-research", "exhaustive")?.maxCredits).toBe(750); + }); +}); + +describe("Firecrawl REST primitives", () => { + beforeEach(async () => { + jest.useRealTimers(); + mockedFetch.mockReset(); + await firecrawlKey.set("fc-unit-test-placeholder"); + }); + + it("validates arguments before network access", async () => { + expect(await firecrawlSearch({ query: "" })).toContain("ERROR"); + expect(await firecrawlScrape({ url: "file:///etc/passwd" }, "firecrawl")).toContain("HTTP(S)"); + expect( + await firecrawlAgent({ prompt: "x" }, undefined), + ).toContain("trusted Firecrawl workflow"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("posts a bounded search request and retains source URLs", async () => { + mockedFetch.mockResolvedValueOnce( + response(200, { + success: true, + data: { web: [{ title: "Docs", url: "https://docs.firecrawl.dev/", description: "API" }] }, + creditsUsed: 2, + }) as never, + ); + const output = await firecrawlSearch({ query: "Firecrawl API", limit: 999, format: "markdown" }); + const [url, init] = mockedFetch.mock.calls[0]; + expect(url).toBe("https://api.firecrawl.dev/v2/search"); + expect(init?.method).toBe("POST"); + const headers = init?.headers as Record; + expect(headers.Authorization).toMatch(/^Bearer /); + const body = JSON.parse(String(init?.body)); + expect(body.limit).toBe(10); + expect(body.scrapeOptions.formats).toEqual([{ type: "markdown" }]); + expect(output).toContain("https://docs.firecrawl.dev/"); + }); + + it("uses a trusted scrape schema, truncates content, and retains the source", async () => { + mockedFetch.mockResolvedValueOnce( + response(200, { + data: { + markdown: "x".repeat(30_000), + metadata: { sourceURL: "https://example.com/article" }, + }, + }) as never, + ); + const output = await firecrawlScrape( + { url: "https://example.com/article", format: "markdown" }, + "firecrawl-market-research", + ); + expect(output.length).toBeLessThan(25_000); + expect(output).toContain("https://example.com/article"); + expect(output).toContain("truncated"); + }); + + it("rejects the exact credential on 401 and maps rate/credit errors", async () => { + mockedFetch.mockResolvedValueOnce(response(401, { error: "unauthorized" }) as never); + expect(await firecrawlSearch({ query: "test" })).toContain("rejected the API key"); + expect(firecrawlKey.getStatus()).toBe("rejected"); + + await firecrawlKey.set("fc-unit-test-placeholder"); + mockedFetch.mockResolvedValueOnce(response(429, { error: "rate" }) as never); + expect(await firecrawlSearch({ query: "test" })).toContain("rate or concurrency limit"); + + mockedFetch.mockResolvedValueOnce(response(402, { error: "credits" }) as never); + expect(await firecrawlSearch({ query: "test" })).toContain("credit limit reached"); + }); + + it("starts Agent once with the central maxCredits and trusted schema, then retains sources", async () => { + mockedFetch + .mockResolvedValueOnce(response(200, { success: true, id: "agent-job-1234" }) as never) + .mockResolvedValueOnce( + response(200, { + success: true, + status: "completed", + data: { finding: "Observed", sources: ["https://example.com/evidence"] }, + creditsUsed: 12, + }) as never, + ); + const output = await firecrawlAgent( + { prompt: "Compare this market", schema: { malicious: true }, maxCredits: 999999 }, + "firecrawl-market-research", + ); + const post = mockedFetch.mock.calls.find(([, init]) => init?.method === "POST"); + const body = JSON.parse(String(post?.[1]?.body)); + expect(body.maxCredits).toBe(250); + expect(body.model).toBe("spark-1-mini"); + expect(body.schema).toEqual(trustedAgentSchema("firecrawl-market-research")); + expect(body.schema).not.toHaveProperty("malicious"); + expect(output).toContain("agent-job-1234"); + expect(output).toContain("https://example.com/evidence"); + }); + + it("resumes by job ID without creating a duplicate paid job", async () => { + mockedFetch.mockResolvedValueOnce( + response(200, { success: true, status: "completed", data: {}, creditsUsed: 4 }) as never, + ); + await firecrawlAgent( + { prompt: "Resume", jobId: "existing-job-1234" }, + "firecrawl-market-research", + ); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch.mock.calls[0][1]?.method).toBe("GET"); + }); + + it("deduplicates identical Agent starts across screen retries", async () => { + mockedFetch + .mockResolvedValueOnce(response(200, { success: true, id: "dedupe-job-1234" }) as never) + .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never) + .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never); + const args = { prompt: "Unique dedupe test prompt" }; + await firecrawlAgent(args, "firecrawl-market-research"); + await firecrawlAgent(args, "firecrawl-market-research"); + expect(mockedFetch.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); + expect(mockedFetch.mock.calls.filter(([, init]) => init?.method === "GET")).toHaveLength(2); + }); + + it("maps failed and cancelled status distinctly", async () => { + mockedFetch.mockResolvedValueOnce( + response(200, { success: true, status: "failed", error: "could not extract" }) as never, + ); + expect(await getFirecrawlAgentStatus("failed-job-1234")).toMatchObject({ status: "failed" }); + mockedFetch.mockResolvedValueOnce( + response(200, { success: true, status: "cancelled" }) as never, + ); + expect(await getFirecrawlAgentStatus("cancel-job-1234")).toMatchObject({ status: "cancelled" }); + }); + + it("polls processing to completed with progress", async () => { + jest.useFakeTimers(); + mockedFetch + .mockResolvedValueOnce(response(200, { success: true, status: "processing" }) as never) + .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never); + const progress = jest.fn(); + const pending = pollFirecrawlAgent("poll-job-1234", 30_000, undefined, progress); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(3_000); + await expect(pending).resolves.toMatchObject({ status: "completed" }); + expect(progress).toHaveBeenCalledWith(expect.objectContaining({ state: "processing" })); + }); + + it("times out with a recoverable job ID and does not POST", async () => { + const now = jest.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(2); + await expect(pollFirecrawlAgent("timeout-job-1234", 1)).rejects.toThrow("timeout-job-1234"); + expect(mockedFetch).not.toHaveBeenCalled(); + now.mockRestore(); + }); + + it("issues documented DELETE cancellation on AbortSignal after start", async () => { + const controller = new AbortController(); + mockedFetch + .mockResolvedValueOnce(response(200, { success: true, id: "abort-job-1234" }) as never) + .mockResolvedValueOnce(response(200, { success: true, status: "processing" }) as never) + .mockResolvedValueOnce(response(200, { success: true }) as never); + const output = await firecrawlAgent( + { prompt: "Bounded research" }, + "firecrawl-market-research", + controller.signal, + (progress) => { + if (progress.state === "processing") controller.abort(); + }, + ); + expect(output).toContain("cancelled locally"); + expect(mockedFetch.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(true); + }); + + it("exposes the standalone cancellation primitive", async () => { + mockedFetch.mockResolvedValueOnce(response(200, { success: true }) as never); + await cancelFirecrawlAgent("cancel-job-1234"); + expect(mockedFetch.mock.calls[0][1]?.method).toBe("DELETE"); + }); +}); diff --git a/__tests__/store.test.ts b/__tests__/store.test.ts index a91ae70..4f8bb68 100644 --- a/__tests__/store.test.ts +++ b/__tests__/store.test.ts @@ -11,11 +11,11 @@ jest.mock("expo-secure-store", () => ({ jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); // Capture stream launches instead of hitting Cerebras. -const streamCalls: Array<{ messages: unknown }> = []; +const streamCalls: Array<{ messages: unknown; handlers?: Record }> = []; jest.mock("../src/genos/stream", () => ({ NEEDS_LIVE_DATA: "needs live data", - streamScreen: jest.fn((messages: unknown) => { - streamCalls.push({ messages }); + streamScreen: jest.fn((messages: unknown, handlers?: Record) => { + streamCalls.push({ messages, handlers }); }), })); @@ -23,10 +23,14 @@ import { cleanLang, extractActions, openApp, + openDeepLink, parseOsCommand, resolveAction, + retryScreen, screenStore, + setActiveScreen, } from "../src/genos/store"; +import { firecrawlKey } from "../src/genos/providers/firecrawl/key"; describe("cleanLang", () => { it("strips a wrapping markdown fence", () => { @@ -118,3 +122,56 @@ describe("resolveAction cache", () => { expect(retried?.speculative).toBe(false); }); }); + +describe("trusted workflow context", () => { + const app = { + id: "research-test", + name: "Research", + emoji: "🔎", + tile: ["#000", "#111"] as [string, string], + request: "Research a market", + workflowId: "firecrawl-market-research", + }; + + beforeAll(async () => { + await firecrawlKey.set("fc-disposable-unit-test-only"); + }); + + it("survives root, action child, form child, prefetch, and retry", () => { + const root = openApp(app); + expect(screenStore.get(root)?.workflowId).toBe(app.workflowId); + expect(streamCalls.at(-1)?.handlers?.workflowId).toBe(app.workflowId); + + const child = resolveAction(root, "Compare vendors"); + const form = resolveAction(root, "Run scoped report", { region: "US" }); + const deep = openDeepLink("notes", "Save the sourced summary", app.workflowId); + expect(screenStore.get(child)?.workflowId).toBe(app.workflowId); + expect(screenStore.get(form)?.workflowId).toBe(app.workflowId); + expect(screenStore.get(deep)?.workflowId).toBe(app.workflowId); + + screenStore.patch(root, { + status: "done", + content: 'root = Button("More", Action([@ToAssistant("Open evidence")]))', + }); + setActiveScreen(root); + const prefetched = screenStore.all().find((screen) => screen.parentId === root && screen.speculative); + expect(prefetched?.workflowId).toBe(app.workflowId); + + const callsBefore = streamCalls.length; + retryScreen(child); + expect(screenStore.get(child)?.workflowId).toBe(app.workflowId); + expect(streamCalls).toHaveLength(callsBefore + 1); + expect(streamCalls.at(-1)?.handlers?.workflowId).toBe(app.workflowId); + }); + + it("does not execute any tool round during speculative prefetch", () => { + const root = openApp({ ...app, id: "prefetch-safety-test" }); + screenStore.patch(root, { + status: "done", + content: 'root = Button("Paid", Action([@ToAssistant("Run paid research")]))', + }); + setActiveScreen(root); + const handler = streamCalls.at(-1)?.handlers?.onToolRound as (() => string) | undefined; + expect(handler?.()).toBe("abort"); + }); +}); diff --git a/__tests__/tools.test.ts b/__tests__/tools.test.ts index 6ca5158..fddac5f 100644 --- a/__tests__/tools.test.ts +++ b/__tests__/tools.test.ts @@ -10,6 +10,7 @@ jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); import { loremflickrUrl, parseImgUrl } from "../src/genos/tools/images"; import { executeTool, formatWebResults } from "../src/genos/tools/search"; +import { definitionsForProviders } from "../src/genos/tools"; describe("semantic image queries (/api/img)", () => { it("parses the prompt's canonical form", () => { @@ -68,3 +69,33 @@ describe("web_search tool", () => { expect(formatWebResults("test", [])).toContain("none found"); }); }); + +describe("provider-scoped tool registry", () => { + const names = (exa: boolean, firecrawl: boolean) => + definitionsForProviders({ exa, firecrawl }).map((tool) => tool.function.name); + + it("composes no keys, Exa only, Firecrawl only, and both providers", () => { + expect(names(false, false)).toEqual([]); + expect(names(true, false)).toEqual(["web_search"]); + expect(names(false, true)).toEqual([ + "firecrawl_search", + "firecrawl_scrape", + "firecrawl_agent", + ]); + expect(names(true, true)).toEqual([ + "web_search", + "firecrawl_search", + "firecrawl_scrape", + "firecrawl_agent", + ]); + }); + + it("never exposes maxCredits or arbitrary schema as model-controlled Agent arguments", () => { + const agent = definitionsForProviders({ exa: false, firecrawl: true }).find( + (tool) => tool.function.name === "firecrawl_agent", + ); + const properties = agent?.function.parameters.properties as Record; + expect(properties.maxCredits).toBeUndefined(); + expect(properties.schema).toBeUndefined(); + }); +}); diff --git a/plans/README.md b/plans/README.md index 702f159..876fd9d 100644 --- a/plans/README.md +++ b/plans/README.md @@ -12,7 +12,7 @@ when done. |------|-------|----------|--------|------------|--------| | 001 | Finish and verify Linear-style global typography | P1 | S | - | DONE | | 002 | Add Raycast-compatible provider favicons | P1 | S | - | DONE | -| 003 | Add the Firecrawl provider runtime | P1 | L | - | TODO | +| 003 | Add the Firecrawl provider runtime | P1 | L | - | DONE | | 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | TODO | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index d4b4eb6..0bcb024 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -21,11 +21,13 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cerebrasKey } from "../config"; +import { firecrawlKey } from "./providers/firecrawl/key"; import type { AppDef } from "./apps"; import { APPS, DEFAULT_TILE, summonApp } from "./apps"; import { genosLibrary } from "./library"; import { HomeScreen } from "./shell/HomeScreen"; import { KeyGate } from "./shell/KeyGate"; +import { ProviderKeyGate } from "./shell/ProviderKeyGate"; import { Switcher, type RunningApp } from "./shell/Switcher"; import { cleanLang, @@ -39,6 +41,7 @@ import { import { useCds } from "./theme"; import { Text } from "./typography"; import { AppsIcon, LucideIcon } from "./ui/icons"; +import { isFirecrawlWorkflow } from "./workflows"; /** Shape of the ActionEvent the Renderer dispatches (subset we use). */ interface GenActionEvent { @@ -188,6 +191,7 @@ function Skeleton() { export default function GenOS() { useSyncExternalStore(screenStore.subscribe, screenStore.getVersion); const keyStatus = useSyncExternalStore(cerebrasKey.subscribe, cerebrasKey.getStatus); + const firecrawlKeyStatus = useSyncExternalStore(firecrawlKey.subscribe, firecrawlKey.getStatus); const t = useCds(); const insets = useSafeAreaInsets(); @@ -200,6 +204,7 @@ export default function GenOS() { const [appMeta, setAppMeta] = useState>({}); const [switcherOpen, setSwitcherOpen] = useState(false); const [toast, setToast] = useState<{ text: string; key: number } | null>(null); + const [dismissedProviderGate, setDismissedProviderGate] = useState(null); const toastTimer = useRef | null>(null); /** Apps the user has sent home - only these show as icons on the home screen. */ @@ -378,7 +383,7 @@ export default function GenOS() { /** genos://open deep link - jump into another app at a specific screen. */ const deepLink = useCallback( (appId: string, request: string) => { - const id = openDeepLink(appId, request); + const id = openDeepLink(appId, request, top?.workflowId); const known = APPS.find((a) => a.id === appId.toLowerCase()); rememberMeta(appId.toLowerCase(), { name: known?.name ?? capitalize(appId), @@ -389,7 +394,7 @@ export default function GenOS() { pushScreen(appId.toLowerCase(), id); activate(appId.toLowerCase()); }, - [activate, rememberMeta, pushScreen], + [activate, rememberMeta, pushScreen, top?.workflowId], ); /** @@ -779,7 +784,13 @@ export default function GenOS() { > - {top?.searching ? "searching the web…" : "materializing…"} + {top?.toolProgress?.state === "starting" + ? "starting Firecrawl agent…" + : top?.toolProgress?.state === "processing" + ? `Firecrawl working… ${Math.floor(top.toolProgress.elapsedMs / 1000)}s` + : top?.searching + ? "searching the web…" + : "materializing…"} )} @@ -823,6 +834,20 @@ export default function GenOS() { /> )} + {topId && + isFirecrawlWorkflow(top?.workflowId) && + (firecrawlKeyStatus === "missing" || firecrawlKeyStatus === "rejected") && + dismissedProviderGate !== topId && ( + setDismissedProviderGate(topId)} + onConnected={() => { + setDismissedProviderGate(null); + retryScreen(topId); + }} + /> + )} + {(keyStatus === "missing" || keyStatus === "rejected") && } ); diff --git a/src/genos/apps.ts b/src/genos/apps.ts index 775f7f4..d439625 100644 --- a/src/genos/apps.ts +++ b/src/genos/apps.ts @@ -7,6 +7,8 @@ export interface AppDef { tile: [string, string]; /** The request sent to the model to open the app's home screen. */ request: string; + /** Optional trusted runtime contract. Plan 004 will attach these to catalog entries. */ + workflowId?: string; } export const DEFAULT_TILE: [string, string] = ["#5e5ce6", "#bf5af2"]; diff --git a/src/genos/providers/firecrawl/key.ts b/src/genos/providers/firecrawl/key.ts new file mode 100644 index 0000000..0efecab --- /dev/null +++ b/src/genos/providers/firecrawl/key.ts @@ -0,0 +1,96 @@ +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; + +export type FirecrawlKeyStatus = "loading" | "missing" | "present" | "rejected"; + +const STORAGE_KEY = "genos.firecrawl-key"; +const ENV_KEY = __DEV__ ? process.env.EXPO_PUBLIC_FIRECRAWL_API_KEY : undefined; + +interface KeyPersistence { + read(): Promise; + write(value: string | null): Promise; +} + +const persistence: KeyPersistence = { + async read() { + if (Platform.OS === "web") { + try { + return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null; + } catch { + return null; + } + } + return SecureStore.getItemAsync(STORAGE_KEY); + }, + async write(value) { + if (Platform.OS === "web") { + try { + if (value === null) globalThis.localStorage?.removeItem(STORAGE_KEY); + else globalThis.localStorage?.setItem(STORAGE_KEY, value); + } catch { + // Private-mode storage can be unavailable; retain the in-memory key. + } + return; + } + if (value === null) await SecureStore.deleteItemAsync(STORAGE_KEY); + else await SecureStore.setItemAsync(STORAGE_KEY, value); + }, +}; + +export class FirecrawlKeyStore { + private key: string | null; + private status: FirecrawlKeyStatus; + private listeners = new Set<() => void>(); + + constructor( + private readonly storage: KeyPersistence = persistence, + environmentKey: string | undefined = ENV_KEY, + ) { + this.key = environmentKey?.trim() || null; + this.status = this.key ? "present" : "loading"; + if (!this.key) void this.hydrate(); + } + + private async hydrate() { + try { + const stored = await this.storage.read(); + if (this.status !== "loading") return; + this.key = stored?.trim() || null; + this.setStatus(this.key ? "present" : "missing"); + } catch { + if (this.status === "loading") this.setStatus("missing"); + } + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getStatus = () => this.status; + + get(): string | null { + return this.key; + } + + async set(value: string) { + this.key = value.trim() || null; + this.setStatus(this.key ? "present" : "missing"); + await this.storage.write(this.key).catch(() => {}); + } + + markRejected(rejectedKey: string) { + if (this.key !== rejectedKey) return; + this.key = null; + this.setStatus("rejected"); + void this.storage.write(null).catch(() => {}); + } + + private setStatus(status: FirecrawlKeyStatus) { + this.status = status; + this.listeners.forEach((listener) => listener()); + } +} + +/** Firecrawl credentials are deliberately independent from the Cerebras gate. */ +export const firecrawlKey = new FirecrawlKeyStore(); diff --git a/src/genos/shell/ProviderKeyGate.tsx b/src/genos/shell/ProviderKeyGate.tsx new file mode 100644 index 0000000..efcc062 --- /dev/null +++ b/src/genos/shell/ProviderKeyGate.tsx @@ -0,0 +1,96 @@ +import React, { useState } from "react"; +import { Linking, Pressable, View } from "react-native"; +import { firecrawlKey, type FirecrawlKeyStatus } from "../providers/firecrawl/key"; +import { useCds } from "../theme"; +import { Text, TextInput, linearType } from "../typography"; + +export function ProviderKeyGate({ + status, + onDismiss, + onConnected, +}: { + status: FirecrawlKeyStatus; + onDismiss: () => void; + onConnected: () => void; +}) { + const t = useCds(); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const valid = value.trim().length >= 10; + + const save = async () => { + if (!valid || saving) return; + setSaving(true); + await firecrawlKey.set(value); + setSaving(false); + onConnected(); + }; + + return ( + + Connect Firecrawl + + This action uses your Firecrawl credits. Your key is stored only on this device and sent + directly to Firecrawl. + + {status === "rejected" && ( + + Firecrawl rejected the saved key. Enter a current key. + + )} + + ({ + paddingVertical: 12, + paddingHorizontal: 34, + borderRadius: 22, + backgroundColor: "#5e5ce6", + opacity: !valid || saving ? 0.4 : pressed ? 0.8 : 1, + })} + > + + {saving ? "Saving…" : "Use Firecrawl"} + + + Linking.openURL("https://www.firecrawl.dev/app/api-keys").catch(() => {})}> + Get a key from Firecrawl + + + Not now + + + ); +} diff --git a/src/genos/store.ts b/src/genos/store.ts index 5d943e1..6f33f88 100644 --- a/src/genos/store.ts +++ b/src/genos/store.ts @@ -2,6 +2,8 @@ import type { AppDef } from "./apps"; import { APPS } from "./apps"; import type { ChatMessage } from "./stream"; import { streamScreen } from "./stream"; +import { firecrawlKey } from "./providers/firecrawl/key"; +import { isFirecrawlWorkflow } from "./workflows"; export type ScreenStatus = "pending" | "streaming" | "done" | "error"; @@ -12,6 +14,8 @@ export interface Screen { /** The user-intent message that produced this screen. */ request: string; parentId?: string; + /** Trusted provider workflow inherited by every descendant screen. */ + workflowId?: string; /** Accumulating openui-lang source. */ content: string; status: ScreenStatus; @@ -30,6 +34,7 @@ export interface Screen { osCommand?: { cmd: "back" | "home" | "switcher" | "open"; arg?: string }; /** The model is running tools (web_search) before composing the screen. */ searching?: boolean; + toolProgress?: { state: "starting" | "processing"; elapsedMs: number; jobId?: string }; } /** Detect a whole-response @OS(...) command (nothing else in the reply). */ @@ -97,7 +102,13 @@ class ScreenStore { // Content is updated synchronously so get()/onDone always see the latest; // only the subscriber notification is throttled. Content flowing also // means any tool round is over - flip the "searching" pill back. - this.screens.set(id, { ...s, content: s.content + delta, status: "streaming", searching: false }); + this.screens.set(id, { + ...s, + content: s.content + delta, + status: "streaming", + searching: false, + toolProgress: undefined, + }); this.scheduleFlush(); } @@ -205,6 +216,7 @@ function startStream(id: string) { streamScreen(buildMessages(screen), { signal: controller.signal, + workflowId: screen.workflowId, onDelta: (delta) => { if (!stale()) screenStore.append(id, delta); }, @@ -218,6 +230,9 @@ function startStream(id: string) { screenStore.patch(id, { content: "", status: "pending", searching: true }); return "continue"; }, + onToolProgress: (toolProgress) => { + if (!stale()) screenStore.patch(id, { searching: true, toolProgress }); + }, onDone: (info) => { if (stale()) return; inflight.delete(id); @@ -229,6 +244,7 @@ function startStream(id: string) { status: "error", error: "The connection dropped mid-screen - retry", searching: false, + toolProgress: undefined, }); return; } @@ -240,13 +256,19 @@ function startStream(id: string) { truncated: info.truncated, osCommand, searching: false, + toolProgress: undefined, }); if (!osCommand) maybePrefetch(id); }, onError: (err) => { if (stale()) return; inflight.delete(id); - screenStore.patch(id, { status: "error", error: err.message, searching: false }); + screenStore.patch(id, { + status: "error", + error: err.message, + searching: false, + toolProgress: undefined, + }); }, }); } @@ -256,6 +278,7 @@ interface LaunchInput { appName: string; request: string; parentId?: string; + workflowId?: string; speculative: boolean; } @@ -268,7 +291,9 @@ function launchScreen(input: LaunchInput): string { status: "pending", startedAt: performance.now(), }); - startStream(id); + // A Firecrawl workflow waits for its provider-scoped gate. Ordinary apps + // continue to launch even when no Firecrawl credential exists. + if (!isFirecrawlWorkflow(input.workflowId) || firecrawlKey.get()) startStream(id); return id; } @@ -300,6 +325,7 @@ export function openApp(app: AppDef): string { appId: app.id, appName: app.name, request: app.request, + workflowId: app.workflowId, speculative: false, }); appHomeIndex.set(app.id, id); @@ -310,8 +336,8 @@ export function openApp(app: AppDef): string { const deepLinkIndex = new Map(); /** Open a screen in another app via a genos://open deep link. */ -export function openDeepLink(appId: string, request: string): string { - const key = `${appId.toLowerCase()} ${request}`; +export function openDeepLink(appId: string, request: string, parentWorkflowId?: string): string { + const key = `${appId.toLowerCase()} ${parentWorkflowId ?? ""} ${request}`; const existing = deepLinkIndex.get(key); if (existing) { const screen = screenStore.get(existing); @@ -326,6 +352,7 @@ export function openDeepLink(appId: string, request: string): string { appId: app?.id ?? appId.toLowerCase(), appName: app?.name ?? appId.charAt(0).toUpperCase() + appId.slice(1), request, + workflowId: parentWorkflowId ?? app?.workflowId, speculative: false, }); deepLinkIndex.set(key, id); @@ -372,6 +399,7 @@ export function resolveAction( appName: parent?.appName ?? "App", request, parentId, + workflowId: parent?.workflowId, speculative: false, }); if (!hasFormValues) actionIndex.set(key, id); @@ -394,6 +422,7 @@ export function retryScreen(id: string) { // tools for prefetched screens that errored with NEEDS_LIVE_DATA. speculative: false, searching: false, + toolProgress: undefined, }); startStream(id); } @@ -421,9 +450,9 @@ function maybePrefetch(id: string) { appName: screen.appName, request: message, parentId: id, + workflowId: screen.workflowId, speculative: true, }); actionIndex.set(key, childId); } } - diff --git a/src/genos/stream.ts b/src/genos/stream.ts index 33e24d9..dbdd4cf 100644 --- a/src/genos/stream.ts +++ b/src/genos/stream.ts @@ -11,7 +11,8 @@ import { fetch as expoFetch } from "expo/fetch"; import { CEREBRAS_BASE_URL, GENOS_MODEL, cerebrasKey } from "../config"; import { SYSTEM_PROMPT } from "./generated/system-prompt"; -import { TOOLS_PROMPT_SECTION, TOOL_DEFS, executeTool, toolsAvailable } from "./tools/search"; +import { enabledPromptSections, enabledToolDefinitions, executeTool, toolsAvailable } from "./tools"; +import { workflowPrompt } from "./workflows"; export interface ToolCall { id: string; @@ -49,6 +50,8 @@ interface StreamHandlers { * actually opens); "continue" executes the calls and streams the next round. */ onToolRound?: (calls: Array<{ name: string; args: Record }>) => "continue" | "abort"; + onToolProgress?: (progress: { state: "starting" | "processing"; elapsedMs: number; jobId?: string }) => void; + workflowId?: string; signal?: AbortSignal; } @@ -106,7 +109,7 @@ function createUtf8Decoder(): (chunk: Uint8Array) => string { } /** System prompt + optional tools section + today's date line. */ -function systemPrompt(): string { +function systemPrompt(workflowId?: string): string { const today = new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", @@ -114,7 +117,7 @@ function systemPrompt(): string { day: "numeric", }); return ( - SYSTEM_PROMPT + (toolsAvailable() ? TOOLS_PROMPT_SECTION : "") + `\n\nToday is ${today}.` + SYSTEM_PROMPT + enabledPromptSections({ workflowId }) + workflowPrompt(workflowId) + `\n\nToday is ${today}.` ); } @@ -134,6 +137,7 @@ async function streamRound( convo: ChatMessage[], includeTools: boolean, onDelta: (text: string) => void, + workflowId?: string, signal?: AbortSignal, ): Promise { const apiKey = cerebrasKey.get(); @@ -147,8 +151,8 @@ async function streamRound( }, body: JSON.stringify({ model: GENOS_MODEL, - messages: [{ role: "system", content: systemPrompt() }, ...convo], - ...(includeTools ? { tools: TOOL_DEFS } : {}), + messages: [{ role: "system", content: systemPrompt(workflowId) }, ...convo], + ...(includeTools ? { tools: enabledToolDefinitions({ workflowId }) } : {}), stream: true, temperature: 0.8, max_completion_tokens: 3072, @@ -247,14 +251,14 @@ async function streamRound( } export async function streamScreen(messages: ChatMessage[], handlers: StreamHandlers) { - const { onDelta, onDone, onError, onToolRound, signal } = handlers; + const { onDelta, onDone, onError, onToolRound, onToolProgress, workflowId, signal } = handlers; const convo: ChatMessage[] = [...messages]; try { for (let round = 0; ; round++) { // Past the round budget, stop offering tools - forces a screen. - const includeTools = toolsAvailable() && round < MAX_TOOL_ROUNDS; - const result = await streamRound(convo, includeTools, onDelta, signal); + const includeTools = toolsAvailable({ workflowId }) && round < MAX_TOOL_ROUNDS; + const result = await streamRound(convo, includeTools, onDelta, workflowId, signal); if (result.finish !== "tool_calls") { onDone(result.info); @@ -280,7 +284,12 @@ export async function streamScreen(messages: ChatMessage[], handlers: StreamHand content: result.content || null, tool_calls: result.toolCalls, }); - const outputs = await Promise.all(calls.map((c) => executeTool(c.name, c.args, signal))); + // Execute sequentially. Agent calls are paid and must never be duplicated + // by parallel/speculative dispatch of the same round. + const outputs: string[] = []; + for (const call of calls) { + outputs.push(await executeTool(call.name, call.args, signal, onToolProgress, { workflowId })); + } if (signal?.aborted) return; result.toolCalls.forEach((tc, i) => { convo.push({ role: "tool", tool_call_id: tc.id, content: outputs[i] }); diff --git a/src/genos/tools/firecrawl.ts b/src/genos/tools/firecrawl.ts new file mode 100644 index 0000000..6318621 --- /dev/null +++ b/src/genos/tools/firecrawl.ts @@ -0,0 +1,410 @@ +import { fetch as expoFetch } from "expo/fetch"; +import { firecrawlKey } from "../providers/firecrawl/key"; +import { + agentPolicy, + trustedAgentSchema, + type FirecrawlWorkflowId, + type WorkflowDepth, +} from "../workflows"; + +const BASE_URL = "https://api.firecrawl.dev/v2"; +const REQUEST_TIMEOUT_MS = 60_000; +const POLL_INTERVAL_MS = 3_000; +const MAX_QUERY = 500; +const MAX_PROMPT = 10_000; +const MAX_URLS = 10; +const MAX_RESULT_CHARS = 24_000; +export const MAX_SEARCH_RESULTS = 10; + +/** Stable for the app session so screen retries resume instead of re-spending. */ +const agentJobs = new Map>(); + +export type FirecrawlProgress = { + state: "starting" | "processing"; + elapsedMs: number; + jobId?: string; +}; + +export interface FirecrawlAgentStatus { + success: boolean; + status: "processing" | "completed" | "failed" | "cancelled"; + data?: unknown; + error?: string; + creditsUsed?: number; + expiresAt?: string; +} + +function validUrl(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + const url = new URL(value.trim()); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +function boundedUrls(value: unknown): string[] | null { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_URLS) return null; + const urls = value.map(validUrl); + return urls.every((url): url is string => !!url) ? urls : null; +} + +function requestSignal(parent: AbortSignal | undefined, timeoutMs: number) { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (parent?.aborted) controller.abort(); + else parent?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(abort, timeoutMs); + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timer); + parent?.removeEventListener("abort", abort); + }, + }; +} + +async function firecrawlRequest( + path: string, + init: { method: "GET" | "POST" | "DELETE"; body?: Record }, + signal?: AbortSignal, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { + const key = firecrawlKey.get(); + if (!key) throw new Error("Firecrawl key required"); + const bounded = requestSignal(signal, timeoutMs); + try { + const response = await expoFetch(`${BASE_URL}${path}`, { + method: init.method, + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + signal: bounded.signal, + }); + if (response.status === 401 || response.status === 403) { + firecrawlKey.markRejected(key); + throw new Error("Firecrawl rejected the API key - enter a valid key"); + } + if (!response.ok) { + const detail = (await response.text().catch(() => "")).slice(0, 500); + if (response.status === 429) { + throw new Error("Firecrawl rate or concurrency limit reached - wait and try again"); + } + if (response.status === 402 || /credit|payment/i.test(detail)) { + throw new Error("Firecrawl credit limit reached - review the account budget"); + } + throw new Error(detail || `Firecrawl HTTP ${response.status}`); + } + return response.json(); + } finally { + bounded.cleanup(); + } +} + +function truncate(value: string): string { + return value.length <= MAX_RESULT_CHARS + ? value + : `${value.slice(0, MAX_RESULT_CHARS)}\n[Result truncated at ${MAX_RESULT_CHARS} characters]`; +} + +function boundedResult(data: unknown, metadata: Record): string { + const complete = JSON.stringify({ data, ...metadata }); + if (complete.length <= MAX_RESULT_CHARS) return complete; + const suffix = JSON.stringify({ ...metadata, truncated: true }); + const budget = Math.max(0, MAX_RESULT_CHARS - suffix.length - 40); + return JSON.stringify({ + data: JSON.stringify(data).slice(0, budget), + ...metadata, + truncated: true, + }); +} + +function collectSources(value: unknown, out = new Set()): string[] { + if (typeof value === "string") { + const url = validUrl(value); + if (url) out.add(url); + } else if (Array.isArray(value)) { + value.forEach((item) => collectSources(item, out)); + } else if (value && typeof value === "object") { + Object.values(value as Record).forEach((item) => collectSources(item, out)); + } + return [...out].slice(0, 100); +} + +export async function firecrawlSearch( + args: Record, + signal?: AbortSignal, +): Promise { + const query = typeof args.query === "string" ? args.query.trim() : ""; + if (!query || query.length > MAX_QUERY) return `ERROR: firecrawl_search requires a query up to ${MAX_QUERY} characters`; + const requested = Number(args.limit ?? 5); + if (!Number.isFinite(requested) || requested < 1) return "ERROR: firecrawl_search limit must be positive"; + const limit = Math.min(MAX_SEARCH_RESULTS, Math.floor(requested)); + const includeMarkdown = args.format === "markdown"; + try { + const json = (await firecrawlRequest( + "/search", + { + method: "POST", + body: { + query, + limit, + sources: ["web"], + ...(includeMarkdown ? { scrapeOptions: { formats: [{ type: "markdown" }] } } : {}), + }, + }, + signal, + )) as { + data?: { web?: Array<{ title?: string; description?: string; url?: string; markdown?: string; metadata?: { sourceURL?: string } }> }; + creditsUsed?: number; + warning?: string; + }; + const results = (json.data?.web ?? []).slice(0, limit).map((result) => ({ + title: result.title ?? "", + url: validUrl(result.url) ?? validUrl(result.metadata?.sourceURL) ?? "", + description: result.description ?? "", + ...(includeMarkdown ? { markdown: (result.markdown ?? "").slice(0, 4_000) } : {}), + })); + return truncate(JSON.stringify({ query, results, creditsUsed: json.creditsUsed, warning: json.warning ?? "", sources: results.map((r) => r.url).filter(Boolean) })); + } catch (error) { + return `ERROR: firecrawl search failed (${error instanceof Error ? error.message : String(error)})`; + } +} + +export async function firecrawlScrape( + args: Record, + workflowId: string | undefined, + signal?: AbortSignal, +): Promise { + const url = validUrl(args.url); + if (!url) return "ERROR: firecrawl_scrape requires one valid HTTP(S) URL"; + const format = args.format === "json" ? "json" : "markdown"; + const schema = format === "json" ? trustedAgentSchema(workflowId) : null; + if (format === "json" && !schema) return "ERROR: JSON scrape requires a trusted Firecrawl workflow"; + try { + const json = (await firecrawlRequest( + "/scrape", + { + method: "POST", + body: { + url, + formats: + format === "json" + ? [{ type: "json", schema }] + : ["markdown"], + onlyMainContent: true, + timeout: REQUEST_TIMEOUT_MS, + }, + }, + signal, + )) as { data?: { markdown?: string; json?: unknown; metadata?: { sourceURL?: string; url?: string } }; creditsUsed?: number }; + const source = validUrl(json.data?.metadata?.sourceURL) ?? validUrl(json.data?.metadata?.url) ?? url; + const data = format === "json" ? json.data?.json ?? {} : json.data?.markdown ?? ""; + return boundedResult(data, { creditsUsed: json.creditsUsed, sources: [source] }); + } catch (error) { + return `ERROR: firecrawl scrape failed (${error instanceof Error ? error.message : String(error)})`; + } +} + +async function startFirecrawlAgent( + args: { + prompt: string; + urls?: string[]; + schema: Record; + maxCredits: number; + model?: "spark-1-mini" | "spark-1-pro"; + }, + signal?: AbortSignal, +): Promise { + if (!Number.isFinite(args.maxCredits) || args.maxCredits <= 0) { + throw new Error("Agent requires an explicit positive maxCredits"); + } + const json = (await firecrawlRequest( + "/agent", + { + method: "POST", + body: { + prompt: args.prompt, + ...(args.urls?.length ? { urls: args.urls, strictConstrainToURLs: true } : {}), + schema: args.schema, + maxCredits: args.maxCredits, + model: args.model ?? "spark-1-mini", + }, + }, + signal, + )) as { success?: boolean; id?: string }; + if (!json.success || !json.id) throw new Error("Firecrawl did not return an Agent job ID"); + return json.id; +} + +export async function getFirecrawlAgentStatus(jobId: string, signal?: AbortSignal): Promise { + if (!/^[a-zA-Z0-9-]{8,100}$/.test(jobId)) throw new Error("Invalid Firecrawl Agent job ID"); + return (await firecrawlRequest(`/agent/${encodeURIComponent(jobId)}`, { method: "GET" }, signal)) as FirecrawlAgentStatus; +} + +export async function cancelFirecrawlAgent(jobId: string): Promise { + await firecrawlRequest(`/agent/${encodeURIComponent(jobId)}`, { method: "DELETE" }, undefined, 10_000); +} + +function pollDelay(signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("aborted")); + const onAbort = () => { + clearTimeout(timer); + reject(new Error("aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, POLL_INTERVAL_MS); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function pollFirecrawlAgent( + jobId: string, + timeoutMs: number, + signal?: AbortSignal, + progress?: (state: FirecrawlProgress) => void, +): Promise { + const startedAt = Date.now(); + for (;;) { + if (signal?.aborted) throw new Error(`Firecrawl Agent cancelled locally (job ${jobId})`); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= timeoutMs) throw new Error(`Firecrawl Agent timed out; resume status for job ${jobId}`); + progress?.({ state: "processing", elapsedMs, jobId }); + const status = await getFirecrawlAgentStatus(jobId, signal); + if (status.status !== "processing") return status; + await pollDelay(signal); + } +} + +export async function firecrawlAgent( + args: Record, + workflowId: string | undefined, + signal?: AbortSignal, + progress?: (state: FirecrawlProgress) => void, +): Promise { + const depth: WorkflowDepth = + args.depth === "thorough" || args.depth === "exhaustive" ? args.depth : "quick"; + const policy = agentPolicy(workflowId, depth); + if (!policy) return "ERROR: firecrawl_agent requires a trusted Firecrawl workflow"; + if ((policy.contract.requiresAgentConfirmation || depth === "exhaustive") && args.confirmCost !== true) { + return "ERROR: explicit Agent cost/depth confirmation is required"; + } + const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; + if (!prompt || prompt.length > MAX_PROMPT) return `ERROR: firecrawl_agent requires a prompt up to ${MAX_PROMPT} characters`; + const urls = boundedUrls(args.urls); + if (!urls) return `ERROR: firecrawl_agent accepts at most ${MAX_URLS} valid HTTP(S) URLs`; + const model = args.model === "spark-1-pro" ? "spark-1-pro" : "spark-1-mini"; + if (model === "spark-1-pro" && args.highAccuracy !== true) return "ERROR: spark-1-pro requires an explicit high-accuracy choice"; + + let jobId = typeof args.jobId === "string" ? args.jobId : undefined; + try { + if (!jobId) { + progress?.({ state: "starting", elapsedMs: 0 }); + const dedupeKey = JSON.stringify([workflowId, depth, model, prompt, urls]); + let start = agentJobs.get(dedupeKey); + if (!start) { + start = startFirecrawlAgent( + { + prompt: `${policy.contract.instructions}\n\nUser task: ${prompt}`, + urls, + schema: trustedAgentSchema(workflowId)!, + maxCredits: policy.maxCredits, + model, + }, + signal, + ); + agentJobs.set(dedupeKey, start); + start.catch(() => { + if (agentJobs.get(dedupeKey) === start) agentJobs.delete(dedupeKey); + }); + } + jobId = await start; + } + const status = await pollFirecrawlAgent(jobId, policy.timeoutMs, signal, progress); + if (status.status === "failed") return `ERROR: Firecrawl Agent failed for job ${jobId} (${status.error ?? "unknown error"})`; + if (status.status === "cancelled") return `ERROR: Firecrawl Agent job ${jobId} was cancelled`; + const sources = collectSources(status.data); + return boundedResult(status.data ?? {}, { jobId, creditsUsed: status.creditsUsed ?? 0, sources }); + } catch (error) { + if (signal?.aborted && jobId) { + await cancelFirecrawlAgent(jobId).catch(() => {}); + return `ERROR: Firecrawl Agent cancelled locally (job ${jobId})`; + } + return `ERROR: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export const FIRECRAWL_TOOL_DEFS = [ + { + type: "function" as const, + function: { + name: "firecrawl_search", + description: "Search the live web with a bounded result count and retained source URLs.", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_RESULTS }, + format: { type: "string", enum: ["summary", "markdown"] }, + }, + required: ["query"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "firecrawl_scrape", + description: "Scrape one HTTP(S) URL into bounded markdown or trusted structured output.", + parameters: { + type: "object", + properties: { url: { type: "string" }, format: { type: "string", enum: ["markdown", "json"] } }, + required: ["url"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "firecrawl_agent", + description: "Run or resume a bounded asynchronous Firecrawl Agent job under the active trusted workflow policy.", + parameters: { + type: "object", + properties: { + prompt: { type: "string" }, + urls: { type: "array", maxItems: MAX_URLS, items: { type: "string" } }, + depth: { type: "string", enum: ["quick", "thorough", "exhaustive"] }, + model: { type: "string", enum: ["spark-1-mini", "spark-1-pro"] }, + highAccuracy: { type: "boolean" }, + confirmCost: { type: "boolean" }, + jobId: { type: "string", description: "Existing job ID to resume without creating another paid job" }, + }, + required: ["prompt"], + }, + }, + }, +]; + +export function isFirecrawlTool(name: string): boolean { + return name === "firecrawl_search" || name === "firecrawl_scrape" || name === "firecrawl_agent"; +} + +export async function executeFirecrawlTool( + name: string, + args: Record, + workflowId: FirecrawlWorkflowId, + signal?: AbortSignal, + progress?: (state: FirecrawlProgress) => void, +): Promise { + if (name === "firecrawl_search") return firecrawlSearch(args, signal); + if (name === "firecrawl_scrape") return firecrawlScrape(args, workflowId, signal); + if (name === "firecrawl_agent") return firecrawlAgent(args, workflowId, signal, progress); + return `ERROR: unknown Firecrawl tool "${name}"`; +} diff --git a/src/genos/tools/index.ts b/src/genos/tools/index.ts new file mode 100644 index 0000000..54b2401 --- /dev/null +++ b/src/genos/tools/index.ts @@ -0,0 +1,71 @@ +import { EXA_API_KEY } from "../../config"; +import { firecrawlKey } from "../providers/firecrawl/key"; +import { isFirecrawlWorkflow } from "../workflows"; +import { + executeFirecrawlTool, + FIRECRAWL_TOOL_DEFS, + isFirecrawlTool, + type FirecrawlProgress, +} from "./firecrawl"; +import { + executeTool as executeSearchTool, + TOOL_DEFS as SEARCH_TOOL_DEFS, + TOOLS_PROMPT_SECTION as SEARCH_PROMPT_SECTION, +} from "./search"; + +export interface ToolContext { + workflowId?: string; +} + +export interface ProviderAvailability { + exa: boolean; + firecrawl: boolean; +} + +export function providerAvailability(context: ToolContext = {}): ProviderAvailability { + return { + exa: !!EXA_API_KEY, + firecrawl: !!firecrawlKey.get() && isFirecrawlWorkflow(context.workflowId), + }; +} + +const FIRECRAWL_PROMPT_SECTION = ` + +## Firecrawl tools +Use only the tools enabled for the active trusted workflow. Prefer search for discovery and scrape for a known URL. Agent is asynchronous and credit-bearing: use it only when autonomous multi-page work is required, never start duplicate jobs, and resume an existing jobId after timeout. Preserve source URLs and blank unavailable fields.`; + +export function definitionsForProviders(available: ProviderAvailability) { + return [ + ...(available.exa ? SEARCH_TOOL_DEFS : []), + ...(available.firecrawl ? FIRECRAWL_TOOL_DEFS : []), + ]; +} + +export function enabledPromptSections(context: ToolContext = {}): string { + const available = providerAvailability(context); + return `${available.exa ? SEARCH_PROMPT_SECTION : ""}${available.firecrawl ? FIRECRAWL_PROMPT_SECTION : ""}`; +} + +export function enabledToolDefinitions(context: ToolContext = {}) { + return definitionsForProviders(providerAvailability(context)); +} + +export function toolsAvailable(context: ToolContext = {}): boolean { + return enabledToolDefinitions(context).length > 0; +} + +export async function executeTool( + name: string, + args: Record, + signal?: AbortSignal, + progress?: (state: FirecrawlProgress) => void, + context: ToolContext = {}, +): Promise { + if (name === "web_search") return executeSearchTool(name, args, signal); + if (isFirecrawlTool(name)) { + if (!firecrawlKey.get()) return "ERROR: Firecrawl key required"; + if (!isFirecrawlWorkflow(context.workflowId)) return "ERROR: Firecrawl tool requires a trusted workflow"; + return executeFirecrawlTool(name, args, context.workflowId, signal, progress); + } + return `ERROR: unknown tool "${name}"`; +} diff --git a/src/genos/workflows.ts b/src/genos/workflows.ts new file mode 100644 index 0000000..4d82e36 --- /dev/null +++ b/src/genos/workflows.ts @@ -0,0 +1,222 @@ +export const FIRECRAWL_WORKFLOW_IDS = [ + "firecrawl-company-directories", + "firecrawl-competitive-intel", + "firecrawl-dashboard-reporting", + "firecrawl-deep-research", + "firecrawl-demo-walkthrough", + "firecrawl-knowledge-base", + "firecrawl-knowledge-ingest", + "firecrawl-lead-gen", + "firecrawl-lead-research", + "firecrawl-market-research", + "firecrawl-qa", + "firecrawl-research-papers", + "firecrawl-seo-audit", + "firecrawl-shop", + "firecrawl-website-design-clone", + "firecrawl-workflows", + "firecrawl", +] as const; + +export type FirecrawlWorkflowId = (typeof FIRECRAWL_WORKFLOW_IDS)[number]; +export type WorkflowDepth = "quick" | "thorough" | "exhaustive"; + +export interface WorkflowContract { + id: FirecrawlWorkflowId; + instructions: string; + resultFields: readonly string[]; + maxCredits: number | Record; + timeoutMs: number | Record; + requiresAgentConfirmation?: boolean; +} + +const contract = ( + id: FirecrawlWorkflowId, + instructions: string, + resultFields: readonly string[], + maxCredits: WorkflowContract["maxCredits"] = 150, + timeoutMs: WorkflowContract["timeoutMs"] = 180_000, + requiresAgentConfirmation = false, +): WorkflowContract => ({ id, instructions, resultFields, maxCredits, timeoutMs, requiresAgentConfirmation }); + +export const FIRECRAWL_WORKFLOWS: Record = { + "firecrawl-company-directories": contract( + "firecrawl-company-directories", + "Require target directory and row/page bound. Collect visible, legitimately accessible fields only; follow bounded pagination, report progress, dedupe records, and use blank values for unavailable fields.", + ["records", "pagination", "missingFields", "sources"], + ), + "firecrawl-competitive-intel": contract( + "firecrawl-competitive-intel", + "Require named competitors and scope. Prefer current pricing, product, feature, and changelog evidence; timestamp comparisons and preserve conflicting or missing claims.", + ["asOf", "comparisons", "conflicts", "missingFields", "sources"], + 250, + 300_000, + ), + "firecrawl-dashboard-reporting": contract( + "firecrawl-dashboard-reporting", + "Require an authorized dashboard/session boundary, metric definitions, and reporting period. Never capture credentials or include them in output; do not cross the authorized session boundary.", + ["period", "metricDefinitions", "metrics", "missingFields", "sources"], + ), + "firecrawl-deep-research": contract( + "firecrawl-deep-research", + "Require quick, thorough, or exhaustive depth. Prefer primary sources; return cited synthesis, risks, uncertainty, and open questions without invented claims.", + ["depth", "summary", "findings", "risks", "openQuestions", "sources"], + { quick: 100, thorough: 300, exhaustive: 750 }, + { quick: 180_000, thorough: 480_000, exhaustive: 900_000 }, + ), + "firecrawl-demo-walkthrough": contract( + "firecrawl-demo-walkthrough", + "Require a bounded product flow. Record observed UX evidence; do not submit, publish, purchase, delete, or make any state-changing action without explicit permission.", + ["steps", "observations", "limitations", "sources"], + ), + "firecrawl-knowledge-base": contract( + "firecrawl-knowledge-base", + "Require scoped source URLs and crawl boundary. Retain provenance, dedupe content, record update timestamps, and state login or access limitations.", + ["documents", "updatedAt", "duplicates", "limitations", "sources"], + ), + "firecrawl-knowledge-ingest": contract( + "firecrawl-knowledge-ingest", + "Require scoped sources and explicit ingest boundary. Normalize and dedupe entries while retaining URL provenance and update timestamps; report login limitations and do not upload elsewhere.", + ["entries", "updatedAt", "duplicates", "limitations", "sources"], + ), + "firecrawl-lead-gen": contract( + "firecrawl-lead-gen", + "Require audience, geography, and result limit. Use legitimately accessible fields only, dedupe companies and people, preserve data gaps, and never bypass access controls or guess contact details.", + ["leads", "missingFields", "methodology", "sources"], + ), + "firecrawl-lead-research": contract( + "firecrawl-lead-research", + "Require named person/company and research scope. Produce a concise sourced brief; separate verified facts from inferred pain points and leave unknown email, phone, funding, and roles blank.", + ["facts", "inferredPainPoints", "missingFields", "sources"], + ), + "firecrawl-market-research": contract( + "firecrawl-market-research", + "Require market, geography, period, and question. Prefer primary sources; date every metric and explain methodology, conflicts, uncertainty, and missing evidence.", + ["asOf", "metrics", "findings", "methodology", "uncertainty", "sources"], + 250, + 300_000, + ), + "firecrawl-qa": contract( + "firecrawl-qa", + "Require bounded target, test charter, and allowed actions. Return reproducible steps and observed evidence; never perform destructive or state-changing submissions.", + ["charter", "checks", "findings", "limitations", "sources"], + ), + "firecrawl-research-papers": contract( + "firecrawl-research-papers", + "Require topic and date/scope. Prefer primary paper or PDF links; capture metadata, methodology, results, and limitations, and never invent citations.", + ["papers", "methodology", "results", "limitations", "sources"], + ), + "firecrawl-seo-audit": contract( + "firecrawl-seo-audit", + "Require site and crawl boundary. Gather metadata and indexability evidence; return prioritized findings with representative page samples and explicit coverage gaps.", + ["boundary", "findings", "pageSamples", "missingFields", "sources"], + ), + "firecrawl-shop": contract( + "firecrawl-shop", + "Require product constraints, geography, and result limit. Capture current price and availability evidence, compare options, and never add to cart or purchase.", + ["constraints", "products", "comparisons", "missingFields", "sources"], + ), + "firecrawl-website-design-clone": contract( + "firecrawl-website-design-clone", + "Require target pages and scope. Record observed design tokens, components, and asset provenance; produce a DESIGN.md-shaped result without copying protected content wholesale.", + ["designMarkdown", "tokens", "components", "assetProvenance", "sources"], + ), + "firecrawl-workflows": contract( + "firecrawl-workflows", + "Act only as a chooser: identify the single concrete Firecrawl workflow that matches the request, collect its required inputs, then route to that contract. Never run an unbounded generic Agent.", + ["workflowId", "requiredInputs", "reason", "sources"], + 150, + 180_000, + true, + ), + firecrawl: contract( + "firecrawl", + "Require an explicit search, scrape, or Agent choice. Search and scrape are preferred; Agent requires an explicit cost/depth confirmation before it may start.", + ["operation", "data", "missingFields", "sources"], + 150, + 180_000, + true, + ), +}; + +export function isFirecrawlWorkflow(id: string | undefined): id is FirecrawlWorkflowId { + return !!id && Object.prototype.hasOwnProperty.call(FIRECRAWL_WORKFLOWS, id); +} + +export function workflowPrompt(id: string | undefined): string { + if (!isFirecrawlWorkflow(id)) return ""; + const c = FIRECRAWL_WORKFLOWS[id]; + return `\n\n## Trusted Firecrawl workflow: ${id}\n${c.instructions}\nReturn only observed facts, retain source URLs, and leave unavailable structured fields blank. Never fabricate email, phone, funding, roles, company facts, or citations.`; +} + +export function agentPolicy( + id: string | undefined, + depth: WorkflowDepth = "quick", +): { maxCredits: number; timeoutMs: number; contract: WorkflowContract } | null { + if (!isFirecrawlWorkflow(id)) return null; + const c = FIRECRAWL_WORKFLOWS[id]; + const maxCredits = typeof c.maxCredits === "number" ? c.maxCredits : c.maxCredits[depth]; + const timeoutMs = typeof c.timeoutMs === "number" ? c.timeoutMs : c.timeoutMs[depth]; + return { maxCredits, timeoutMs, contract: c }; +} + +export function trustedAgentSchema(id: string | undefined): Record | null { + if (!isFirecrawlWorkflow(id)) return null; + const fields = FIRECRAWL_WORKFLOWS[id].resultFields; + const scalar = { + anyOf: [ + { type: "string", maxLength: 12_000 }, + { type: "number" }, + { type: "boolean" }, + { type: "null" }, + ], + }; + const stringFields = new Set([ + "asOf", + "period", + "updatedAt", + "summary", + "methodology", + "uncertainty", + "designMarkdown", + "boundary", + "charter", + "depth", + "workflowId", + "reason", + "operation", + ]); + const stringArrays = new Set([ + "conflicts", + "missingFields", + "risks", + "openQuestions", + "duplicates", + "limitations", + "requiredInputs", + ]); + const objectFields = new Set(["metricDefinitions", "constraints", "data"]); + const fieldSchema = (field: string): Record => { + if (field === "sources") { + return { type: "array", maxItems: 100, items: { type: "string", format: "uri" } }; + } + if (stringFields.has(field)) return { type: "string", maxLength: 12_000 }; + if (stringArrays.has(field)) { + return { type: "array", maxItems: 100, items: { type: "string", maxLength: 2_000 } }; + } + if (objectFields.has(field)) { + return { type: "object", maxProperties: 100, additionalProperties: scalar }; + } + return { + type: "array", + maxItems: 100, + items: { type: "object", maxProperties: 50, additionalProperties: scalar }, + }; + }; + return { + type: "object", + additionalProperties: false, + properties: Object.fromEntries(fields.map((field) => [field, fieldSchema(field)])), + required: [...fields], + }; +} From 4f9694ad37c22e87f23f732594c886469046536b Mon Sep 17 00:00:00 2001 From: eaczydot Date: Sun, 16 Aug 2026 21:13:51 -0400 Subject: [PATCH 5/5] Add Firecrawl slash commands and workflow setup --- README.md | 54 ++++++- __tests__/commands.test.tsx | 209 ++++++++++++++++++++++++++++ __tests__/firecrawl.test.ts | 17 ++- __tests__/store.test.ts | 27 ++++ __tests__/tools.test.ts | 47 ++++++- plans/README.md | 2 +- src/genos/GenOS.tsx | 74 +++++++++- src/genos/apps.ts | 4 + src/genos/commands.ts | 150 ++++++++++++++++++++ src/genos/shell/CommandMenu.tsx | 105 ++++++++++++++ src/genos/shell/HomeScreen.tsx | 78 ++++++++++- src/genos/shell/Switcher.tsx | 8 +- src/genos/shell/WorkflowSetup.tsx | 168 ++++++++++++++++++++++ src/genos/store.ts | 29 +++- src/genos/stream.ts | 37 +++-- src/genos/tools/firecrawl.ts | 4 +- src/genos/tools/index.ts | 20 ++- src/genos/typography.tsx | 9 +- src/genos/workflows.ts | 224 ++++++++++++++++++++++++++++++ 19 files changed, 1224 insertions(+), 42 deletions(-) create mode 100644 __tests__/commands.test.tsx create mode 100644 src/genos/commands.ts create mode 100644 src/genos/shell/CommandMenu.tsx create mode 100644 src/genos/shell/WorkflowSetup.tsx diff --git a/README.md b/README.md index 0b6e630..827ffce 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,53 @@ paid credits, and cancellation may accrue a small amount while an in-flight step reaches a stopping point. Results are capped before returning to the model while source URLs and recoverable Agent job IDs are retained. -Current limitations: the specialized Firecrawl slash-command catalog is not -included yet; authenticated dashboards work only inside an already authorized -session boundary; login walls, CAPTCHAs, robots restrictions, paywalls, CSV -uploads, CRM writes, outreach, purchases, and destructive submissions are not -supported. Firecrawl Agent is a Research Preview API, so its isolated protocol -adapter should be rechecked against the official v2 documentation before -release. +### Firecrawl slash commands + +Type `/` in the home ask field to open the Firecrawl command menu. Partial text +such as `/fir` or `/lead` filters the menu only; execution requires an exact +slash ID. A command can include an initial argument, for example +`/firecrawl https://example.com` or +`/firecrawl-lead-research Small public company`. Unknown slash commands are +never sent to the generic model route. + +The enabled catalog is: + +- `/firecrawl` +- `/firecrawl-company-directories` +- `/firecrawl-competitive-intel` +- `/firecrawl-dashboard-reporting` +- `/firecrawl-deep-research` +- `/firecrawl-demo-walkthrough` +- `/firecrawl-knowledge-base` +- `/firecrawl-knowledge-ingest` +- `/firecrawl-lead-gen` +- `/firecrawl-lead-research` +- `/firecrawl-market-research` +- `/firecrawl-qa` +- `/firecrawl-research-papers` +- `/firecrawl-seo-audit` +- `/firecrawl-shop` +- `/firecrawl-website-design-clone` +- `/firecrawl-workflows` + +Every command is BYOK-gated and opens a deterministic setup form before any +Firecrawl request. The form validates required text, URL, bounded number, +selection, and confirmation inputs locally. It shows the central maximum credit +budget for the selected workflow/depth and requires explicit confirmation; +Agent tasks also enforce that confirmation in the runtime. Maximums are credit +ceilings, not expected spend: most workflows cap at 150 credits, competitive +and market research at 250, and deep research at 100 quick, 300 thorough, or +750 exhaustive credits. Base URL/query work still requires confirmation before +its paid scrape/search request starts. + +Results are in-app progress, summary, structured table/list, data-gap, and +source-link views. AppLess does **not** currently export CSV/JSON/files, upload +source files, write to a CRM, send outreach, make purchases, or perform +destructive submissions. Authenticated dashboards work only inside an already +authorized session boundary; login walls, CAPTCHAs, robots restrictions, and +paywalls are not bypassed. Firecrawl Agent is a Research Preview API, so its +isolated protocol adapter should be rechecked against the official v2 +documentation before release. ## Packages diff --git a/__tests__/commands.test.tsx b/__tests__/commands.test.tsx new file mode 100644 index 0000000..76f38e2 --- /dev/null +++ b/__tests__/commands.test.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + FIRECRAWL_COMMANDS, + MAX_COMMAND_INPUT, + commandAvailability, + commandToApp, + filterSlashCommands, + parseSlashCommand, +} from "../src/genos/commands"; +import { CommandMenu, moveCommandSelection } from "../src/genos/shell/CommandMenu"; +import { WorkflowSetup, initialWorkflowValues } from "../src/genos/shell/WorkflowSetup"; +import { + FIRECRAWL_WORKFLOWS, + FIRECRAWL_WORKFLOW_IDS, + FIRECRAWL_WORKFLOW_SETUPS, + setupCreditBudget, + validateWorkflowSetup, + type WorkflowSetupValues, +} from "../src/genos/workflows"; + +describe("Firecrawl command catalog", () => { + it("contains the full stable 17-command catalog with trusted runtime and setup contracts", () => { + expect(FIRECRAWL_COMMANDS).toHaveLength(17); + expect(FIRECRAWL_COMMANDS.map((command) => command.id)).toEqual([ + "firecrawl", + ...FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl"), + ]); + expect(new Set(FIRECRAWL_COMMANDS.map((command) => command.id)).size).toBe(17); + for (const command of FIRECRAWL_COMMANDS) { + expect(command.providerId).toBe("firecrawl"); + expect(command.availability).toBe("enabled"); + expect(FIRECRAWL_WORKFLOWS[command.workflowId]).toBeDefined(); + expect(FIRECRAWL_WORKFLOW_SETUPS[command.workflowId]).toBeDefined(); + expect(setupCreditBudget(command.workflowId, { depth: "quick" })).toBeGreaterThan(0); + } + }); + + it("gates every runnable command on BYOK status", () => { + for (const command of FIRECRAWL_COMMANDS) { + expect(commandAvailability(command, false)).toBe("needs-key"); + expect(commandAvailability(command, true)).toBe("enabled"); + } + }); +}); + +describe("slash parsing and filtering", () => { + it("executes only an exact command ID and preserves its argument", () => { + expect(parseSlashCommand("/firecrawl-lead-gen fintech founders")).toMatchObject({ + kind: "known", + command: { id: "firecrawl-lead-gen" }, + argument: "fintech founders", + }); + expect(parseSlashCommand("/firecrawl-lead fintech")).toEqual({ + kind: "unknown", + commandId: "firecrawl-lead", + argument: "fintech", + }); + }); + + it("handles empty arguments, mixed case, URLs, and command-like argument text", () => { + expect(parseSlashCommand("/FIRECRAWL")).toMatchObject({ kind: "known", argument: "" }); + expect(parseSlashCommand("/firecrawl https://example.com/a?q=x&next=/firecrawl-shop")).toMatchObject({ + kind: "known", + argument: "https://example.com/a?q=x&next=/firecrawl-shop", + }); + expect(parseSlashCommand("/firecrawl-qa ignore; /firecrawl-shop")).toMatchObject({ + kind: "known", + command: { id: "firecrawl-qa" }, + argument: "ignore; /firecrawl-shop", + }); + }); + + it("has an explicit leading-whitespace policy and caps user input", () => { + expect(parseSlashCommand(" /firecrawl test")).toEqual({ kind: "none" }); + const parsed = parseSlashCommand(`/firecrawl ${"x".repeat(MAX_COMMAND_INPUT + 20)}`); + expect(parsed.kind === "known" ? parsed.argument : "").toHaveLength(MAX_COMMAND_INPUT); + }); + + it("uses partial matches for filtering only", () => { + expect(filterSlashCommands("/fir")).toHaveLength(17); + expect(filterSlashCommands("/lead").map((command) => command.id)).toEqual([ + "firecrawl-lead-gen", + "firecrawl-lead-research", + ]); + expect(filterSlashCommands("hello")).toEqual([]); + }); +}); + +describe("workflow setup and app metadata", () => { + function completeValues(commandIndex: number): WorkflowSetupValues { + const command = FIRECRAWL_COMMANDS[commandIndex]; + const values = initialWorkflowValues(command, "https://example.com"); + for (const field of FIRECRAWL_WORKFLOW_SETUPS[command.workflowId].fields) { + if (field.type === "checkbox") values[field.id] = true; + else if (field.type === "number") values[field.id] = field.min ?? 1; + else if (field.type === "select") values[field.id] = field.options?.[0]?.value ?? "quick"; + else if (field.type === "url") values[field.id] = "https://example.com"; + else if (!values[field.id]) values[field.id] = "bounded public scope"; + } + return values; + } + + it("has deterministic valid typed inputs for every enabled workflow", () => { + FIRECRAWL_COMMANDS.forEach((command, index) => { + expect(validateWorkflowSetup(command.workflowId, completeValues(index))).toEqual({}); + }); + }); + + it("requires the explicit credit confirmation and validates URLs and bounds locally", () => { + expect(validateWorkflowSetup("firecrawl-deep-research", { topic: "AI", depth: "quick" })).toHaveProperty("confirmCredits"); + expect(validateWorkflowSetup("firecrawl-seo-audit", { + siteUrl: "file:///private/data", + boundary: "one section", + pageCap: 101, + depth: "quick", + confirmCredits: true, + })).toMatchObject({ siteUrl: expect.any(String), pageCap: expect.any(String) }); + const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!; + expect(() => commandToApp(command, "AI", { topic: "AI", depth: "quick" })).toThrow( + "Workflow setup must be complete before launch", + ); + }); + + it("creates a stable provider/workflow app without secrets or export claims", () => { + const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-lead-research")!; + const values = { ...completeValues(FIRECRAWL_COMMANDS.indexOf(command)), apiKey: "must-not-leak" }; + const first = commandToApp(command, "Small public company", values); + const second = commandToApp(command, "Small public company", values); + expect(first.id).toBe(second.id); + expect(first).toMatchObject({ providerId: "firecrawl", workflowId: command.id }); + expect(first.request).toContain("Small public company"); + expect(first.request).not.toContain("must-not-leak"); + expect(first.request).toContain("Do not claim"); + }); +}); + +describe("CommandMenu and WorkflowSetup rendering", () => { + let tree: ReactTestRenderer | undefined; + afterEach(() => { + if (tree) act(() => tree?.unmount()); + tree = undefined; + }); + + function renderMenu(text: string, hasProviderKey: boolean, onSelect = jest.fn(), onNeedsKey = jest.fn()) { + act(() => { + tree = create( + , + ); + }); + return { onSelect, onNeedsKey }; + } + + it("renders filtered, selected, key-required, empty, and accessible states", () => { + const callbacks = renderMenu("/lead", false); + const rows = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function"); + expect(new Set(rows.map((row) => row.props.accessibilityLabel)).size).toBe(2); + expect(rows[0].props.accessibilityState).toMatchObject({ selected: true, disabled: false }); + expect(rows[0].props.accessibilityLabel).toContain("Connect key"); + act(() => rows[0].props.onPress()); + expect(callbacks.onNeedsKey).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" })); + act(() => tree!.unmount()); + tree = undefined; + renderMenu("/not-a-command", true); + expect(tree!.root.findByProps({ accessibilityLabel: "No matching slash commands" })).toBeTruthy(); + }); + + it("supports deterministic up/down wrapping and enabled tap selection", () => { + expect(moveCommandSelection(0, -1, 17)).toBe(16); + expect(moveCommandSelection(16, 1, 17)).toBe(0); + const callbacks = renderMenu("/lead", true); + const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function")[0]; + act(() => row.props.onPress()); + expect(callbacks.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" })); + }); + + it("renders unavailable rows as disabled with an explanation", () => { + const command = FIRECRAWL_COMMANDS[0] as { availability: "enabled" | "unavailable" }; + command.availability = "unavailable"; + try { + renderMenu("/firecrawl", true); + const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl,") && typeof node.props.onPress === "function")[0]; + expect(row.props.accessibilityState.disabled).toBe(true); + expect(row.props.accessibilityLabel).toContain("Unavailable"); + } finally { + command.availability = "enabled"; + } + }); + + it("does not submit incomplete onboarding or make a provider call", () => { + const onSubmit = jest.fn(); + const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!; + act(() => { + tree = create(); + }); + const run = tree!.root.findByProps({ accessibilityLabel: "Run with maximum 100 credits" }); + act(() => run.props.onPress()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(JSON.stringify(tree!.toJSON())).toContain("is required"); + }); +}); diff --git a/__tests__/firecrawl.test.ts b/__tests__/firecrawl.test.ts index 6f95ee8..77c9e56 100644 --- a/__tests__/firecrawl.test.ts +++ b/__tests__/firecrawl.test.ts @@ -185,7 +185,7 @@ describe("Firecrawl REST primitives", () => { }) as never, ); const output = await firecrawlAgent( - { prompt: "Compare this market", schema: { malicious: true }, maxCredits: 999999 }, + { prompt: "Compare this market", depth: "quick", confirmCost: true, schema: { malicious: true }, maxCredits: 999999 }, "firecrawl-market-research", ); const post = mockedFetch.mock.calls.find(([, init]) => init?.method === "POST"); @@ -203,7 +203,7 @@ describe("Firecrawl REST primitives", () => { response(200, { success: true, status: "completed", data: {}, creditsUsed: 4 }) as never, ); await firecrawlAgent( - { prompt: "Resume", jobId: "existing-job-1234" }, + { prompt: "Resume", depth: "quick", confirmCost: true, jobId: "existing-job-1234" }, "firecrawl-market-research", ); expect(mockedFetch).toHaveBeenCalledTimes(1); @@ -215,7 +215,7 @@ describe("Firecrawl REST primitives", () => { .mockResolvedValueOnce(response(200, { success: true, id: "dedupe-job-1234" }) as never) .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never) .mockResolvedValueOnce(response(200, { success: true, status: "completed", data: {} }) as never); - const args = { prompt: "Unique dedupe test prompt" }; + const args = { prompt: "Unique dedupe test prompt", depth: "quick", confirmCost: true }; await firecrawlAgent(args, "firecrawl-market-research"); await firecrawlAgent(args, "firecrawl-market-research"); expect(mockedFetch.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); @@ -260,7 +260,7 @@ describe("Firecrawl REST primitives", () => { .mockResolvedValueOnce(response(200, { success: true, status: "processing" }) as never) .mockResolvedValueOnce(response(200, { success: true }) as never); const output = await firecrawlAgent( - { prompt: "Bounded research" }, + { prompt: "Bounded research", depth: "quick", confirmCost: true }, "firecrawl-market-research", controller.signal, (progress) => { @@ -271,6 +271,15 @@ describe("Firecrawl REST primitives", () => { expect(mockedFetch.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(true); }); + it("refuses every Agent run without explicit central cost confirmation", async () => { + const output = await firecrawlAgent( + { prompt: "Do not start", depth: "quick" }, + "firecrawl-market-research", + ); + expect(output).toContain("explicit Agent cost/depth confirmation"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + it("exposes the standalone cancellation primitive", async () => { mockedFetch.mockResolvedValueOnce(response(200, { success: true }) as never); await cancelFirecrawlAgent("cancel-job-1234"); diff --git a/__tests__/store.test.ts b/__tests__/store.test.ts index 4f8bb68..70e340a 100644 --- a/__tests__/store.test.ts +++ b/__tests__/store.test.ts @@ -31,6 +31,7 @@ import { setActiveScreen, } from "../src/genos/store"; import { firecrawlKey } from "../src/genos/providers/firecrawl/key"; +import { FIRECRAWL_COMMANDS, commandToApp } from "../src/genos/commands"; describe("cleanLang", () => { it("strips a wrapping markdown fence", () => { @@ -174,4 +175,30 @@ describe("trusted workflow context", () => { const handler = streamCalls.at(-1)?.handlers?.onToolRound as (() => string) | undefined; expect(handler?.()).toBe("abort"); }); + + it("launches a configured slash workflow once with provider/workflow metadata", () => { + const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-lead-research")!; + const app = commandToApp(command, "Acme public company", { + company: "Acme public company", + meetingContext: "Intro meeting", + depth: "quick", + confirmCredits: true, + }); + const before = streamCalls.length; + const root = openApp(app); + expect(streamCalls).toHaveLength(before + 1); + expect(streamCalls.at(-1)?.handlers).toMatchObject({ + workflowId: "firecrawl-lead-research", + firecrawlConfirmed: true, + firecrawlOperation: "agent", + }); + expect(screenStore.get(root)).toMatchObject({ + appId: app.id, + workflowId: "firecrawl-lead-research", + firecrawlConfirmed: true, + workflowInputs: expect.objectContaining({ confirmCredits: true }), + request: expect.stringContaining("Acme public company"), + }); + expect(app.providerId).toBe("firecrawl"); + }); }); diff --git a/__tests__/tools.test.ts b/__tests__/tools.test.ts index fddac5f..491c52e 100644 --- a/__tests__/tools.test.ts +++ b/__tests__/tools.test.ts @@ -10,7 +10,12 @@ jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); import { loremflickrUrl, parseImgUrl } from "../src/genos/tools/images"; import { executeTool, formatWebResults } from "../src/genos/tools/search"; -import { definitionsForProviders } from "../src/genos/tools"; +import { + definitionsForProviders, + enabledToolDefinitions, + executeTool as executeRegistryTool, +} from "../src/genos/tools"; +import { firecrawlKey } from "../src/genos/providers/firecrawl/key"; describe("semantic image queries (/api/img)", () => { it("parses the prompt's canonical form", () => { @@ -98,4 +103,44 @@ describe("provider-scoped tool registry", () => { expect(properties.maxCredits).toBeUndefined(); expect(properties.schema).toBeUndefined(); }); + + it("does not expose or execute Firecrawl before deterministic budget confirmation", async () => { + await firecrawlKey.set("fc-disposable-unit-test-only"); + expect(enabledToolDefinitions({ workflowId: "firecrawl-market-research" })).toEqual([]); + expect( + enabledToolDefinitions({ + workflowId: "firecrawl-market-research", + firecrawlConfirmed: true, + }).map((tool) => tool.function.name), + ).toEqual(["firecrawl_search", "firecrawl_scrape", "firecrawl_agent"]); + expect( + enabledToolDefinitions({ + workflowId: "firecrawl-market-research", + firecrawlConfirmed: true, + firecrawlOperation: "agent", + }).map((tool) => tool.function.name), + ).toEqual(["firecrawl_agent"]); + await expect( + executeRegistryTool( + "firecrawl_search", + { query: "must not run" }, + undefined, + undefined, + { workflowId: "firecrawl-market-research" }, + ), + ).resolves.toContain("explicit Firecrawl setup and budget confirmation"); + await expect( + executeRegistryTool( + "firecrawl_search", + { query: "wrong operation" }, + undefined, + undefined, + { + workflowId: "firecrawl-market-research", + firecrawlConfirmed: true, + firecrawlOperation: "agent", + }, + ), + ).resolves.toContain("confirmed the agent operation"); + }); }); diff --git a/plans/README.md b/plans/README.md index 876fd9d..97208b3 100644 --- a/plans/README.md +++ b/plans/README.md @@ -13,7 +13,7 @@ when done. | 001 | Finish and verify Linear-style global typography | P1 | S | - | DONE | | 002 | Add Raycast-compatible provider favicons | P1 | S | - | DONE | | 003 | Add the Firecrawl provider runtime | P1 | L | - | DONE | -| 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | TODO | +| 004 | Add the Firecrawl slash-command catalog and menu | P1 | M | 002, 003 | DONE | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale) diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 0bcb024..76496d0 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -24,10 +24,16 @@ import { cerebrasKey } from "../config"; import { firecrawlKey } from "./providers/firecrawl/key"; import type { AppDef } from "./apps"; import { APPS, DEFAULT_TILE, summonApp } from "./apps"; +import { + commandToApp, + parseSlashCommand, + type SlashCommandDef, +} from "./commands"; import { genosLibrary } from "./library"; import { HomeScreen } from "./shell/HomeScreen"; import { KeyGate } from "./shell/KeyGate"; import { ProviderKeyGate } from "./shell/ProviderKeyGate"; +import { WorkflowSetup } from "./shell/WorkflowSetup"; import { Switcher, type RunningApp } from "./shell/Switcher"; import { cleanLang, @@ -42,6 +48,7 @@ import { useCds } from "./theme"; import { Text } from "./typography"; import { AppsIcon, LucideIcon } from "./ui/icons"; import { isFirecrawlWorkflow } from "./workflows"; +import type { WorkflowSetupValues } from "./workflows"; /** Shape of the ActionEvent the Renderer dispatches (subset we use). */ interface GenActionEvent { @@ -55,6 +62,7 @@ interface AppMeta { emoji: string; tile: [string, string]; providerId?: string; + workflowId?: string; } const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); @@ -205,6 +213,8 @@ export default function GenOS() { const [switcherOpen, setSwitcherOpen] = useState(false); const [toast, setToast] = useState<{ text: string; key: number } | null>(null); const [dismissedProviderGate, setDismissedProviderGate] = useState(null); + const [pendingWorkflow, setPendingWorkflow] = useState<{ command: SlashCommandDef; argument: string } | null>(null); + const [pendingProviderGate, setPendingProviderGate] = useState(false); const toastTimer = useRef | null>(null); /** Apps the user has sent home - only these show as icons on the home screen. */ @@ -313,6 +323,7 @@ export default function GenOS() { emoji: app.emoji, tile: app.tile, providerId: app.providerId, + workflowId: app.workflowId, }); // openApp touches the screen store (which notifies subscribers), so it // must run here in the event handler - never inside a setState updater. @@ -383,18 +394,25 @@ export default function GenOS() { /** genos://open deep link - jump into another app at a specific screen. */ const deepLink = useCallback( (appId: string, request: string) => { - const id = openDeepLink(appId, request, top?.workflowId); + const id = openDeepLink( + appId, + request, + top?.workflowId, + top?.firecrawlConfirmed, + top?.workflowInputs, + ); const known = APPS.find((a) => a.id === appId.toLowerCase()); rememberMeta(appId.toLowerCase(), { name: known?.name ?? capitalize(appId), emoji: known?.emoji ?? "✨", tile: known?.tile ?? DEFAULT_TILE, providerId: known?.providerId, + workflowId: known?.workflowId, }); pushScreen(appId.toLowerCase(), id); activate(appId.toLowerCase()); }, - [activate, rememberMeta, pushScreen, top?.workflowId], + [activate, rememberMeta, pushScreen, top?.workflowId, top?.firecrawlConfirmed, top?.workflowInputs], ); /** @@ -527,6 +545,24 @@ export default function GenOS() { return; } + // Slash execution is exact and precedes all app/model routing. Leading + // whitespace is intentionally not a slash command, and unknown slash + // input never falls through to an unrelated active app or Cerebras. + const slash = parseSlashCommand(transcript); + if (slash.kind === "unknown") { + showToast(`Unknown slash command: /${slash.commandId || ""}`); + return; + } + if (slash.kind === "known") { + if (slash.command.availability === "unavailable") { + showToast(`/${slash.command.id} is unavailable`); + return; + } + setPendingWorkflow({ command: slash.command, argument: slash.argument }); + if (!firecrawlKey.get()) setPendingProviderGate(true); + return; + } + // "open/launch/switch to " jumps to a known app from anywhere. const openMatch = lower.match(/^(?:open|launch|switch to|go to)\s+(.+)$/); const known = APPS.find((a) => (openMatch?.[1] ?? lower).includes(a.name.toLowerCase())); @@ -544,7 +580,7 @@ export default function GenOS() { request: `Open the perfect app screen for this request: "${text}". Invent a polished, realistic screen that fulfils it.`, }); }, - [activeApp, topId, launch, goBack, goHome, pushScreen, closeSession], + [activeApp, topId, launch, goBack, goHome, pushScreen, closeSession, showToast], ); // Memoized: HomeScreen is React.memo'd and stays mounted under active @@ -559,6 +595,7 @@ export default function GenOS() { emoji: appMeta[id]?.emoji ?? "✨", tile: appMeta[id]?.tile ?? DEFAULT_TILE, providerId: appMeta[id]?.providerId, + workflowId: appMeta[id]?.workflowId, })), [recentOrder, sessions, appMeta], ); @@ -579,6 +616,7 @@ export default function GenOS() { runningApps={homeApps} onResume={activate} onClose={closeSession} + hasFirecrawlKey={firecrawlKeyStatus === "present"} /> {top && ( @@ -834,7 +872,37 @@ export default function GenOS() { /> )} + {pendingWorkflow && ( + { + setPendingWorkflow(null); + setPendingProviderGate(false); + }} + onSubmit={(values: WorkflowSetupValues) => { + const app = commandToApp(pendingWorkflow.command, pendingWorkflow.argument, values); + setPendingWorkflow(null); + setPendingProviderGate(false); + launch(app); + }} + /> + )} + + {pendingProviderGate && + (firecrawlKeyStatus === "missing" || firecrawlKeyStatus === "rejected") && ( + { + setPendingProviderGate(false); + setPendingWorkflow(null); + }} + onConnected={() => setPendingProviderGate(false)} + /> + )} + {topId && + !pendingProviderGate && isFirecrawlWorkflow(top?.workflowId) && (firecrawlKeyStatus === "missing" || firecrawlKeyStatus === "rejected") && dismissedProviderGate !== topId && ( diff --git a/src/genos/apps.ts b/src/genos/apps.ts index d439625..4ae1401 100644 --- a/src/genos/apps.ts +++ b/src/genos/apps.ts @@ -9,6 +9,10 @@ export interface AppDef { request: string; /** Optional trusted runtime contract. Plan 004 will attach these to catalog entries. */ workflowId?: string; + /** Validated, non-secret shell inputs retained through workflow navigation. */ + workflowInputs?: Record; + /** Only the deterministic setup form may set this after explicit confirmation. */ + firecrawlConfirmed?: boolean; } export const DEFAULT_TILE: [string, string] = ["#5e5ce6", "#bf5af2"]; diff --git a/src/genos/commands.ts b/src/genos/commands.ts new file mode 100644 index 0000000..a9f0d3d --- /dev/null +++ b/src/genos/commands.ts @@ -0,0 +1,150 @@ +import type { AppDef } from "./apps"; +import { getProvider } from "./providers"; +import { + FIRECRAWL_WORKFLOWS, + FIRECRAWL_WORKFLOW_IDS, + FIRECRAWL_WORKFLOW_SETUPS, + isFirecrawlWorkflow, + setupCreditBudget, + validateWorkflowSetup, + type FirecrawlWorkflowId, + type WorkflowSetupValues, +} from "./workflows"; + +export const MAX_COMMAND_INPUT = 10_000; + +export type SlashCommandAvailability = "enabled" | "needs-key" | "unavailable"; + +export interface SlashCommandDef { + id: FirecrawlWorkflowId; + title: string; + description: string; + providerId: "firecrawl"; + workflowId: FirecrawlWorkflowId; + inputHint: string; + keywords: readonly string[]; + availability: "enabled" | "unavailable"; +} + +const COMMAND_COPY: Record> = { + firecrawl: { title: "Firecrawl", description: "Choose a bounded scrape, search, or Agent task", inputHint: "URL or query", keywords: ["scrape", "search", "agent"] }, + "firecrawl-company-directories": { title: "Company directories", description: "Extract bounded, deduplicated public directory records", inputHint: "directory URL or name", keywords: ["directory", "companies", "list"] }, + "firecrawl-competitive-intel": { title: "Competitive intel", description: "Compare current pricing, features, and product evidence", inputHint: "competitors and scope", keywords: ["competitor", "pricing", "features"] }, + "firecrawl-dashboard-reporting": { title: "Dashboard reporting", description: "Summarize an authorized dashboard boundary", inputHint: "authorized dashboard URL", keywords: ["dashboard", "metrics", "report"] }, + "firecrawl-deep-research": { title: "Deep research", description: "Run cited research at an explicit depth and budget", inputHint: "research topic", keywords: ["research", "citations", "synthesis"] }, + "firecrawl-demo-walkthrough": { title: "Demo walkthrough", description: "Observe a bounded public product flow without changes", inputHint: "public URL and flow", keywords: ["demo", "walkthrough", "ux"] }, + "firecrawl-knowledge-base": { title: "Knowledge base", description: "Build an in-app sourced knowledge view", inputHint: "source URLs and boundary", keywords: ["knowledge", "docs", "crawl"] }, + "firecrawl-knowledge-ingest": { title: "Knowledge ingest", description: "Normalize bounded sources into an in-app view", inputHint: "source URLs", keywords: ["ingest", "normalize", "dedupe"] }, + "firecrawl-lead-gen": { title: "Lead generation", description: "Find bounded public leads with explicit data gaps", inputHint: "target audience", keywords: ["leads", "prospects", "companies"] }, + "firecrawl-lead-research": { title: "Lead research", description: "Create a concise sourced company or person brief", inputHint: "company or URL", keywords: ["lead", "company", "person", "brief"] }, + "firecrawl-market-research": { title: "Market research", description: "Research dated market evidence and uncertainty", inputHint: "market and question", keywords: ["market", "metrics", "methodology"] }, + "firecrawl-qa": { title: "QA", description: "Run a bounded, read-only website test charter", inputHint: "target URL and charter", keywords: ["qa", "test", "bugs"] }, + "firecrawl-research-papers": { title: "Research papers", description: "Find primary papers with methods and limitations", inputHint: "paper topic", keywords: ["papers", "academic", "pdf"] }, + "firecrawl-seo-audit": { title: "SEO audit", description: "Audit bounded metadata and indexability evidence", inputHint: "site URL", keywords: ["seo", "metadata", "indexability"] }, + "firecrawl-shop": { title: "Shop", description: "Compare current public price and availability evidence", inputHint: "product and constraints", keywords: ["shopping", "price", "products"] }, + "firecrawl-website-design-clone": { title: "Website design study", description: "Document observed design tokens and components", inputHint: "target page URL", keywords: ["website", "design", "tokens", "components"] }, + "firecrawl-workflows": { title: "Workflow chooser", description: "Choose one concrete bounded Firecrawl workflow", inputHint: "your goal", keywords: ["workflow", "chooser", "route"] }, +}; + +const FIRECRAWL_COMMAND_IDS: readonly FirecrawlWorkflowId[] = [ + "firecrawl", + ...FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl"), +]; + +export const FIRECRAWL_COMMANDS: readonly SlashCommandDef[] = FIRECRAWL_COMMAND_IDS.map((id) => ({ + id, + providerId: "firecrawl" as const, + workflowId: id, + ...COMMAND_COPY[id], + availability: + getProvider("firecrawl") && FIRECRAWL_WORKFLOWS[id] && FIRECRAWL_WORKFLOW_SETUPS[id] + ? "enabled" + : "unavailable", +})); + +export type SlashParseResult = + | { kind: "none" } + | { kind: "unknown"; commandId: string; argument: string } + | { kind: "known"; command: SlashCommandDef; argument: string }; + +/** Exact execution parser. Prefix matches are deliberately never executable. */ +export function parseSlashCommand(text: string): SlashParseResult { + if (!text.startsWith("/")) return { kind: "none" }; + const match = text.match(/^\/([^\s]*)(?:\s([\s\S]*))?$/); + if (!match) return { kind: "unknown", commandId: text.slice(1), argument: "" }; + const commandId = match[1].toLowerCase(); + const argument = (match[2] ?? "").slice(0, MAX_COMMAND_INPUT); + const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === commandId); + return command + ? { kind: "known", command, argument } + : { kind: "unknown", commandId, argument }; +} + +/** Discovery helper. Partial matches filter only; route execution uses the parser above. */ +export function filterSlashCommands(text: string): readonly SlashCommandDef[] { + if (!text.startsWith("/")) return []; + const query = text.slice(1).split(/\s/, 1)[0].toLowerCase(); + if (!query) return FIRECRAWL_COMMANDS; + return FIRECRAWL_COMMANDS.filter((command) => + [command.id, command.title, command.description, ...command.keywords] + .join(" ") + .toLowerCase() + .includes(query), + ); +} + +export function commandAvailability( + command: SlashCommandDef, + hasProviderKey: boolean, +): SlashCommandAvailability { + if (command.availability === "unavailable" || !isFirecrawlWorkflow(command.workflowId)) return "unavailable"; + return hasProviderKey ? "enabled" : "needs-key"; +} + +function stableHash(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +export function commandToApp( + command: SlashCommandDef, + argument: string, + setupValues: WorkflowSetupValues = {}, +): AppDef { + const validationErrors = validateWorkflowSetup(command.workflowId, setupValues); + if (Object.keys(validationErrors).length > 0) { + throw new Error("Workflow setup must be complete before launch"); + } + const safeArgument = argument.slice(0, MAX_COMMAND_INPUT); + const safeValues = Object.fromEntries( + Object.entries(setupValues).filter(([key]) => !/key|secret|token|password/i.test(key)), + ); + const budget = setupCreditBudget(command.workflowId, safeValues); + const operation = command.workflowId === "firecrawl" ? safeValues.operation : "agent"; + const factualTask = safeArgument ? `Original command argument: ${safeArgument}\n` : ""; + const request = [ + `Run the trusted ${command.workflowId} workflow.`, + factualTask, + `Validated setup inputs: ${JSON.stringify(safeValues)}`, + `The user explicitly confirmed the central maximum budget of ${budget} credits.`, + `Use exactly one confirmed Firecrawl ${operation} operation for this workflow.`, + "For an Agent tool call, pass the selected depth and confirmCost: true; never exceed the workflow-owned central policy.", + "Render an in-app summary/current-progress view with structured lists or tables, data gaps, and source links.", + "Do not claim a CSV, JSON, file, CRM record, outreach message, purchase, or export was created.", + ].filter(Boolean).join("\n"); + return { + id: `workflow-${command.id}-${stableHash(JSON.stringify([safeArgument, safeValues]))}`, + name: command.title, + emoji: "🔥", + providerId: command.providerId, + workflowId: command.workflowId, + workflowInputs: safeValues, + firecrawlConfirmed: true, + tile: ["#fa5d3b", "#f59e0b"], + request, + }; +} diff --git a/src/genos/shell/CommandMenu.tsx b/src/genos/shell/CommandMenu.tsx new file mode 100644 index 0000000..2c2d584 --- /dev/null +++ b/src/genos/shell/CommandMenu.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { + commandAvailability, + filterSlashCommands, + type SlashCommandDef, +} from "../commands"; +import { Text, linearType } from "../typography"; +import { ProviderIcon } from "../ui/ProviderIcon"; + +export function moveCommandSelection(current: number, delta: number, count: number): number { + if (count <= 0) return -1; + return (Math.max(0, current) + delta + count) % count; +} + +export function CommandMenu({ + text, + hasProviderKey, + highlightedIndex, + onHighlightedIndexChange, + onSelect, + onNeedsKey, + onDismiss, +}: { + text: string; + hasProviderKey: boolean; + highlightedIndex: number; + onHighlightedIndexChange: (index: number) => void; + onSelect: (command: SlashCommandDef) => void; + onNeedsKey: (command: SlashCommandDef) => void; + onDismiss: () => void; +}) { + const commands = filterSlashCommands(text); + + return ( + + + + FIRECRAWL COMMANDS · exact command + Enter to run + + + Escape + + + {commands.length === 0 ? ( + + + No matching Firecrawl command. This text will not be sent as a generic request. + + + ) : ( + + {commands.map((command, index) => { + const availability = commandAvailability(command, hasProviderKey); + const unavailable = availability === "unavailable"; + const label = availability === "enabled" ? "Ready" : availability === "needs-key" ? "Connect key" : "Unavailable"; + return ( + onHighlightedIndexChange(index)} + onPress={() => availability === "needs-key" ? onNeedsKey(command) : onSelect(command)} + style={({ pressed }) => ({ + flexDirection: "row", + alignItems: "center", + gap: 11, + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: index === highlightedIndex ? "rgba(255,255,255,0.12)" : "transparent", + opacity: unavailable ? 0.45 : pressed ? 0.65 : 1, + })} + > + + + + /{command.id} + + + {command.description} + + + + {label} + + + ); + })} + + )} + + ); +} diff --git a/src/genos/shell/HomeScreen.tsx b/src/genos/shell/HomeScreen.tsx index 9fe01ee..77e7f96 100644 --- a/src/genos/shell/HomeScreen.tsx +++ b/src/genos/shell/HomeScreen.tsx @@ -28,17 +28,26 @@ import React, { useEffect, useRef, useState } from "react"; import { Animated, ImageBackground, + type NativeSyntheticEvent, Platform, Pressable, ScrollView, + TextInput as NativeTextInput, + type TextInputKeyPressEventData, View, } from "react-native"; import { SvgXml } from "react-native-svg"; import type { Suggestion } from "../apps"; import { SUGGESTIONS } from "../apps"; +import { + commandAvailability, + filterSlashCommands, + parseSlashCommand, +} from "../commands"; import { Text, TextInput, linearType } from "../typography"; import { ProviderIcon } from "../ui/ProviderIcon"; import { APPLESS_LOGO_XML } from "./applessLogo"; +import { CommandMenu, moveCommandSelection } from "./CommandMenu"; import type { RunningApp } from "./Switcher"; const SUGGESTION_ICONS: Record = { @@ -311,6 +320,7 @@ export const HomeScreen = React.memo(function HomeScreen({ runningApps, onResume, onClose, + hasFirecrawlKey, }: { topInset: number; /** True while an app screen is rendered over the home screen. */ @@ -319,6 +329,7 @@ export const HomeScreen = React.memo(function HomeScreen({ runningApps: RunningApp[]; onResume: (appId: string) => void; onClose: (appId: string) => void; + hasFirecrawlKey: boolean; }) { const [ask, setAsk] = useState(""); const [slots, setSlots] = useState([0, 1, 2]); @@ -326,6 +337,10 @@ export const HomeScreen = React.memo(function HomeScreen({ const [editingId, setEditingId] = useState(null); const nextIdx = useRef(3); const turn = useRef(0); + const inputRef = useRef(null); + const [highlightedCommand, setHighlightedCommand] = useState(0); + const menuOpen = ask.startsWith("/"); + const filteredCommands = filterSlashCommands(ask); // Every 4s swap out one suggestion (cycling through the rows in order). // Paused while covered - no point animating under an app screen. The refs @@ -347,12 +362,36 @@ export const HomeScreen = React.memo(function HomeScreen({ const visible: Suggestion[] = slots.map((i) => SUGGESTIONS[i]); const submit = () => { - const text = ask.trim(); + const text = ask; if (!text) return; + const parsed = parseSlashCommand(text); + if (menuOpen && parsed.kind !== "known" && filteredCommands[highlightedCommand]) { + const command = filteredCommands[highlightedCommand]; + if (commandAvailability(command, hasFirecrawlKey) === "needs-key") { + onCommand(`/${command.id}`); + setAsk(""); + } else { + setAsk(`/${command.id} `); + requestAnimationFrame(() => inputRef.current?.focus()); + } + return; + } onCommand(text); setAsk(""); }; + const handleKeyPress = (event: NativeSyntheticEvent) => { + if (!menuOpen) return; + const key = event.nativeEvent.key; + if (key === "ArrowDown" || key === "ArrowUp") { + event.preventDefault?.(); + setHighlightedCommand((current) => moveCommandSelection(current, key === "ArrowDown" ? 1 : -1, filteredCommands.length)); + } else if (key === "Escape") { + event.preventDefault?.(); + setAsk(""); + } + }; + return ( - - {visible.map((s, i) => ( - onCommand(s.command)} /> - ))} - + {!menuOpen && ( + + {visible.map((s, i) => ( + onCommand(s.command)} /> + ))} + + )} + + {menuOpen && ( + { + setAsk(`/${command.id} `); + requestAnimationFrame(() => inputRef.current?.focus()); + }} + onNeedsKey={(command) => { + onCommand(`/${command.id}`); + setAsk(""); + }} + onDismiss={() => setAsk("")} + /> + )} { + setAsk(value); + setHighlightedCommand(0); + }} + onKeyPress={handleKeyPress} onSubmitEditing={submit} returnKeyType="go" placeholder="Ask for anything…" diff --git a/src/genos/shell/Switcher.tsx b/src/genos/shell/Switcher.tsx index 12ac286..5d33afc 100644 --- a/src/genos/shell/Switcher.tsx +++ b/src/genos/shell/Switcher.tsx @@ -14,6 +14,7 @@ export interface RunningApp { emoji: string; tile: [string, string]; providerId?: string; + workflowId?: string; } const CARD_W = 188; @@ -65,7 +66,7 @@ export function Switcher({ const screenId = topScreenId(app.id); const screen = screenId ? screenStore.get(screenId) : undefined; return ( - + {app.providerId ? ( @@ -106,6 +107,11 @@ export function Switcher({ + {!!app.workflowId && ( + + /{app.workflowId} + + )} onResume(app.id)} style={{ diff --git a/src/genos/shell/WorkflowSetup.tsx b/src/genos/shell/WorkflowSetup.tsx new file mode 100644 index 0000000..df41727 --- /dev/null +++ b/src/genos/shell/WorkflowSetup.tsx @@ -0,0 +1,168 @@ +import React, { useMemo, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import type { SlashCommandDef } from "../commands"; +import { + FIRECRAWL_WORKFLOW_SETUPS, + setupCreditBudget, + validateWorkflowSetup, + visibleWorkflowFields, + type WorkflowFieldDef, + type WorkflowSetupValues, +} from "../workflows"; +import { useCds } from "../theme"; +import { Text, TextInput, linearType } from "../typography"; +import { ProviderIcon } from "../ui/ProviderIcon"; + +export function initialWorkflowValues(command: SlashCommandDef, argument: string): WorkflowSetupValues { + const values: WorkflowSetupValues = {}; + const fields = FIRECRAWL_WORKFLOW_SETUPS[command.workflowId].fields; + const firstTaskField = fields.find((candidate) => + candidate.required && (candidate.type === "text" || candidate.type === "url") && !candidate.visibleWhen, + ); + if (firstTaskField && argument.trim()) values[firstTaskField.id] = argument.slice(0, 10_000); + for (const candidate of fields) { + if (candidate.type === "select" && candidate.options?.length) values[candidate.id] = candidate.options[0].value; + if (candidate.type === "number" && candidate.required) values[candidate.id] = candidate.min ?? 1; + if (candidate.type === "checkbox") values[candidate.id] = false; + } + if (command.workflowId === "firecrawl" && argument.trim()) { + try { + const url = new URL(argument.trim()); + if (url.protocol === "http:" || url.protocol === "https:") { + values.operation = "scrape"; + values.url = argument.trim(); + } + } catch { + values.operation = "search"; + values.query = argument.slice(0, 10_000); + } + } else if (command.workflowId === "firecrawl") { + delete values.operation; + } + return values; +} + +function Field({ + definition, + value, + error, + onChange, +}: { + definition: WorkflowFieldDef; + value: string | number | boolean | undefined; + error?: string; + onChange: (value: string | number | boolean) => void; +}) { + const t = useCds(); + if (definition.type === "checkbox") { + return ( + onChange(value !== true)} + style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingVertical: 8 }} + > + + {value === true && } + + + {definition.label} + {!!definition.hint && {definition.hint}} + {!!error && {error}} + + + ); + } + if (definition.type === "select") { + return ( + + {definition.label} + + {definition.options?.map((option) => ( + onChange(option.value)} + style={{ paddingVertical: 7, paddingHorizontal: 11, borderRadius: 14, backgroundColor: value === option.value ? t.tint : t.fill }} + > + {option.label} + + ))} + + {!!error && {error}} + + ); + } + return ( + + {definition.label} + onChange(definition.type === "number" ? next : next.slice(0, 10_000))} + placeholder={definition.placeholder} + placeholderTextColor={t.ink3} + autoCapitalize="none" + keyboardType={definition.type === "number" ? "number-pad" : definition.type === "url" ? "url" : "default"} + style={{ borderWidth: 1, borderColor: error ? t.red : t.sep, backgroundColor: t.group, borderRadius: 11, paddingVertical: 10, paddingHorizontal: 12, color: t.ink, fontSize: 13 }} + /> + {!!error && {error}} + + ); +} + +export function WorkflowSetup({ command, argument, onCancel, onSubmit }: { + command: SlashCommandDef; + argument: string; + onCancel: () => void; + onSubmit: (values: WorkflowSetupValues) => void; +}) { + const t = useCds(); + const [values, setValues] = useState(() => initialWorkflowValues(command, argument)); + const [errors, setErrors] = useState>({}); + const fields = useMemo(() => visibleWorkflowFields(command.workflowId, values), [command.workflowId, values]); + const budget = setupCreditBudget(command.workflowId, values); + + const submit = () => { + const nextErrors = validateWorkflowSetup(command.workflowId, values); + setErrors(nextErrors); + if (Object.keys(nextErrors).length === 0) onSubmit(values); + }; + + return ( + + + + + + {command.title} + Deterministic workflow setup + + + + Complete the required inputs below. Nothing is sent to Firecrawl until you confirm the displayed budget and press Run. + + {fields.map((definition) => ( + setValues((current) => ({ ...current, [definition.id]: value }))} /> + ))} + + Maximum budget: {budget} credits + This is a ceiling, not expected spend. Exhaustive research can use up to 750 credits. + + + + Cancel + + + Run workflow + + + + Results stay in AppLess as structured views with sources. File/CSV export is not available. + + + + ); +} diff --git a/src/genos/store.ts b/src/genos/store.ts index 6f33f88..17390e1 100644 --- a/src/genos/store.ts +++ b/src/genos/store.ts @@ -16,6 +16,8 @@ export interface Screen { parentId?: string; /** Trusted provider workflow inherited by every descendant screen. */ workflowId?: string; + workflowInputs?: Record; + firecrawlConfirmed?: boolean; /** Accumulating openui-lang source. */ content: string; status: ScreenStatus; @@ -217,6 +219,13 @@ function startStream(id: string) { streamScreen(buildMessages(screen), { signal: controller.signal, workflowId: screen.workflowId, + firecrawlConfirmed: screen.firecrawlConfirmed, + firecrawlOperation: + screen.workflowId === "firecrawl" + ? (screen.workflowInputs?.operation as "search" | "scrape" | "agent" | undefined) + : isFirecrawlWorkflow(screen.workflowId) + ? "agent" + : undefined, onDelta: (delta) => { if (!stale()) screenStore.append(id, delta); }, @@ -279,6 +288,8 @@ interface LaunchInput { request: string; parentId?: string; workflowId?: string; + workflowInputs?: Record; + firecrawlConfirmed?: boolean; speculative: boolean; } @@ -326,6 +337,8 @@ export function openApp(app: AppDef): string { appName: app.name, request: app.request, workflowId: app.workflowId, + workflowInputs: app.workflowInputs, + firecrawlConfirmed: app.firecrawlConfirmed, speculative: false, }); appHomeIndex.set(app.id, id); @@ -336,8 +349,14 @@ export function openApp(app: AppDef): string { const deepLinkIndex = new Map(); /** Open a screen in another app via a genos://open deep link. */ -export function openDeepLink(appId: string, request: string, parentWorkflowId?: string): string { - const key = `${appId.toLowerCase()} ${parentWorkflowId ?? ""} ${request}`; +export function openDeepLink( + appId: string, + request: string, + parentWorkflowId?: string, + firecrawlConfirmed = false, + workflowInputs?: Record, +): string { + const key = `${appId.toLowerCase()} ${parentWorkflowId ?? ""} ${firecrawlConfirmed ? "confirmed" : "unconfirmed"} ${JSON.stringify(workflowInputs ?? {})} ${request}`; const existing = deepLinkIndex.get(key); if (existing) { const screen = screenStore.get(existing); @@ -353,6 +372,8 @@ export function openDeepLink(appId: string, request: string, parentWorkflowId?: appName: app?.name ?? appId.charAt(0).toUpperCase() + appId.slice(1), request, workflowId: parentWorkflowId ?? app?.workflowId, + workflowInputs: workflowInputs ?? app?.workflowInputs, + firecrawlConfirmed: firecrawlConfirmed || app?.firecrawlConfirmed, speculative: false, }); deepLinkIndex.set(key, id); @@ -400,6 +421,8 @@ export function resolveAction( request, parentId, workflowId: parent?.workflowId, + workflowInputs: parent?.workflowInputs, + firecrawlConfirmed: parent?.firecrawlConfirmed, speculative: false, }); if (!hasFormValues) actionIndex.set(key, id); @@ -451,6 +474,8 @@ function maybePrefetch(id: string) { request: message, parentId: id, workflowId: screen.workflowId, + workflowInputs: screen.workflowInputs, + firecrawlConfirmed: screen.firecrawlConfirmed, speculative: true, }); actionIndex.set(key, childId); diff --git a/src/genos/stream.ts b/src/genos/stream.ts index dbdd4cf..7aae9e0 100644 --- a/src/genos/stream.ts +++ b/src/genos/stream.ts @@ -11,7 +11,7 @@ import { fetch as expoFetch } from "expo/fetch"; import { CEREBRAS_BASE_URL, GENOS_MODEL, cerebrasKey } from "../config"; import { SYSTEM_PROMPT } from "./generated/system-prompt"; -import { enabledPromptSections, enabledToolDefinitions, executeTool, toolsAvailable } from "./tools"; +import { enabledPromptSections, enabledToolDefinitions, executeTool, isFirecrawlTool, toolsAvailable } from "./tools"; import { workflowPrompt } from "./workflows"; export interface ToolCall { @@ -52,6 +52,8 @@ interface StreamHandlers { onToolRound?: (calls: Array<{ name: string; args: Record }>) => "continue" | "abort"; onToolProgress?: (progress: { state: "starting" | "processing"; elapsedMs: number; jobId?: string }) => void; workflowId?: string; + firecrawlConfirmed?: boolean; + firecrawlOperation?: "search" | "scrape" | "agent"; signal?: AbortSignal; } @@ -109,7 +111,7 @@ function createUtf8Decoder(): (chunk: Uint8Array) => string { } /** System prompt + optional tools section + today's date line. */ -function systemPrompt(workflowId?: string): string { +function systemPrompt(workflowId?: string, firecrawlConfirmed = false): string { const today = new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", @@ -117,7 +119,7 @@ function systemPrompt(workflowId?: string): string { day: "numeric", }); return ( - SYSTEM_PROMPT + enabledPromptSections({ workflowId }) + workflowPrompt(workflowId) + `\n\nToday is ${today}.` + SYSTEM_PROMPT + enabledPromptSections({ workflowId, firecrawlConfirmed }) + workflowPrompt(workflowId) + `\n\nToday is ${today}.` ); } @@ -138,6 +140,8 @@ async function streamRound( includeTools: boolean, onDelta: (text: string) => void, workflowId?: string, + firecrawlConfirmed = false, + firecrawlOperation?: "search" | "scrape" | "agent", signal?: AbortSignal, ): Promise { const apiKey = cerebrasKey.get(); @@ -151,8 +155,8 @@ async function streamRound( }, body: JSON.stringify({ model: GENOS_MODEL, - messages: [{ role: "system", content: systemPrompt(workflowId) }, ...convo], - ...(includeTools ? { tools: enabledToolDefinitions({ workflowId }) } : {}), + messages: [{ role: "system", content: systemPrompt(workflowId, firecrawlConfirmed) }, ...convo], + ...(includeTools ? { tools: enabledToolDefinitions({ workflowId, firecrawlConfirmed, firecrawlOperation }) } : {}), stream: true, temperature: 0.8, max_completion_tokens: 3072, @@ -251,14 +255,24 @@ async function streamRound( } export async function streamScreen(messages: ChatMessage[], handlers: StreamHandlers) { - const { onDelta, onDone, onError, onToolRound, onToolProgress, workflowId, signal } = handlers; + const { onDelta, onDone, onError, onToolRound, onToolProgress, workflowId, firecrawlConfirmed, firecrawlOperation, signal } = handlers; const convo: ChatMessage[] = [...messages]; + let firecrawlCallStarted = false; try { for (let round = 0; ; round++) { // Past the round budget, stop offering tools - forces a screen. - const includeTools = toolsAvailable({ workflowId }) && round < MAX_TOOL_ROUNDS; - const result = await streamRound(convo, includeTools, onDelta, workflowId, signal); + const context = { workflowId, firecrawlConfirmed, firecrawlOperation }; + const includeTools = toolsAvailable(context) && round < MAX_TOOL_ROUNDS; + const result = await streamRound( + convo, + includeTools, + onDelta, + workflowId, + firecrawlConfirmed, + firecrawlOperation, + signal, + ); if (result.finish !== "tool_calls") { onDone(result.info); @@ -288,7 +302,12 @@ export async function streamScreen(messages: ChatMessage[], handlers: StreamHand // by parallel/speculative dispatch of the same round. const outputs: string[] = []; for (const call of calls) { - outputs.push(await executeTool(call.name, call.args, signal, onToolProgress, { workflowId })); + if (isFirecrawlTool(call.name) && firecrawlCallStarted) { + outputs.push("ERROR: this workflow already started its one confirmed Firecrawl operation"); + continue; + } + if (isFirecrawlTool(call.name)) firecrawlCallStarted = true; + outputs.push(await executeTool(call.name, call.args, signal, onToolProgress, context)); } if (signal?.aborted) return; result.toolCalls.forEach((tc, i) => { diff --git a/src/genos/tools/firecrawl.ts b/src/genos/tools/firecrawl.ts index 6318621..58ab3eb 100644 --- a/src/genos/tools/firecrawl.ts +++ b/src/genos/tools/firecrawl.ts @@ -293,7 +293,7 @@ export async function firecrawlAgent( args.depth === "thorough" || args.depth === "exhaustive" ? args.depth : "quick"; const policy = agentPolicy(workflowId, depth); if (!policy) return "ERROR: firecrawl_agent requires a trusted Firecrawl workflow"; - if ((policy.contract.requiresAgentConfirmation || depth === "exhaustive") && args.confirmCost !== true) { + if (args.confirmCost !== true) { return "ERROR: explicit Agent cost/depth confirmation is required"; } const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; @@ -386,7 +386,7 @@ export const FIRECRAWL_TOOL_DEFS = [ confirmCost: { type: "boolean" }, jobId: { type: "string", description: "Existing job ID to resume without creating another paid job" }, }, - required: ["prompt"], + required: ["prompt", "depth", "confirmCost"], }, }, }, diff --git a/src/genos/tools/index.ts b/src/genos/tools/index.ts index 54b2401..5c8d03b 100644 --- a/src/genos/tools/index.ts +++ b/src/genos/tools/index.ts @@ -7,6 +7,7 @@ import { isFirecrawlTool, type FirecrawlProgress, } from "./firecrawl"; +export { isFirecrawlTool } from "./firecrawl"; import { executeTool as executeSearchTool, TOOL_DEFS as SEARCH_TOOL_DEFS, @@ -15,6 +16,8 @@ import { export interface ToolContext { workflowId?: string; + firecrawlConfirmed?: boolean; + firecrawlOperation?: "search" | "scrape" | "agent"; } export interface ProviderAvailability { @@ -25,7 +28,10 @@ export interface ProviderAvailability { export function providerAvailability(context: ToolContext = {}): ProviderAvailability { return { exa: !!EXA_API_KEY, - firecrawl: !!firecrawlKey.get() && isFirecrawlWorkflow(context.workflowId), + firecrawl: + !!firecrawlKey.get() && + context.firecrawlConfirmed === true && + isFirecrawlWorkflow(context.workflowId), }; } @@ -47,7 +53,13 @@ export function enabledPromptSections(context: ToolContext = {}): string { } export function enabledToolDefinitions(context: ToolContext = {}) { - return definitionsForProviders(providerAvailability(context)); + const definitions = definitionsForProviders(providerAvailability(context)); + if (!context.firecrawlOperation) return definitions; + return definitions.filter( + (definition) => + !isFirecrawlTool(definition.function.name) || + definition.function.name === `firecrawl_${context.firecrawlOperation}`, + ); } export function toolsAvailable(context: ToolContext = {}): boolean { @@ -64,7 +76,11 @@ export async function executeTool( if (name === "web_search") return executeSearchTool(name, args, signal); if (isFirecrawlTool(name)) { if (!firecrawlKey.get()) return "ERROR: Firecrawl key required"; + if (context.firecrawlConfirmed !== true) return "ERROR: explicit Firecrawl setup and budget confirmation required"; if (!isFirecrawlWorkflow(context.workflowId)) return "ERROR: Firecrawl tool requires a trusted workflow"; + if (context.firecrawlOperation && name !== `firecrawl_${context.firecrawlOperation}`) { + return `ERROR: this workflow confirmed the ${context.firecrawlOperation} operation, not ${name}`; + } return executeFirecrawlTool(name, args, context.workflowId, signal, progress); } return `ERROR: unknown tool "${name}"`; diff --git a/src/genos/typography.tsx b/src/genos/typography.tsx index 62ccea5..b0017a4 100644 --- a/src/genos/typography.tsx +++ b/src/genos/typography.tsx @@ -60,11 +60,14 @@ export function Text({ style, ...props }: TextProps) { return ; } -export function TextInput({ style, ...props }: TextInputProps) { +export const TextInput = React.forwardRef(function TextInput( + { style, ...props }, + ref, +) { const loaded = useContext(TypographyReadyContext); const fontFamily = familyForStyle(style, loaded); - return ; -} + return ; +}); /** Linear-inspired hierarchy using Inter, its closest public substitute. */ export const linearType = { diff --git a/src/genos/workflows.ts b/src/genos/workflows.ts index 4d82e36..c8d8179 100644 --- a/src/genos/workflows.ts +++ b/src/genos/workflows.ts @@ -30,6 +30,55 @@ export interface WorkflowContract { requiresAgentConfirmation?: boolean; } +export type WorkflowFieldType = "text" | "url" | "number" | "select" | "checkbox"; + +export interface WorkflowFieldDef { + id: string; + label: string; + type: WorkflowFieldType; + required?: boolean; + placeholder?: string; + hint?: string; + min?: number; + max?: number; + options?: readonly { label: string; value: string }[]; + visibleWhen?: { fieldId: string; equals: string }; +} + +export interface WorkflowSetupDef { + workflowId: FirecrawlWorkflowId; + fields: readonly WorkflowFieldDef[]; +} + +export type WorkflowSetupValues = Record; + +const field = ( + id: string, + label: string, + type: WorkflowFieldType, + options: Omit = {}, +): WorkflowFieldDef => ({ id, label, type, ...options }); + +const depthField = (exhaustive = false): WorkflowFieldDef => + field("depth", "Research depth", "select", { + required: true, + options: [ + { label: "Quick", value: "quick" }, + { label: "Thorough", value: "thorough" }, + ...(exhaustive ? [{ label: "Exhaustive", value: "exhaustive" }] : []), + ], + }); + +const confirmationField = field("confirmCredits", "Confirm the displayed maximum credit budget", "checkbox", { + required: true, + hint: "This confirmation is required before AppLess starts any Firecrawl request.", +}); + +const setup = ( + workflowId: FirecrawlWorkflowId, + fields: readonly WorkflowFieldDef[], +): WorkflowSetupDef => ({ workflowId, fields: [...fields, confirmationField] }); + const contract = ( id: FirecrawlWorkflowId, instructions: string, @@ -139,6 +188,181 @@ export const FIRECRAWL_WORKFLOWS: Record ), }; +const OUTPUT_OPTIONS = [ + { label: "Structured list", value: "list" }, + { label: "Table", value: "table" }, +] as const; + +/** + * Shell-owned setup contracts. These are deliberately application data, not + * local agent SKILL.md files, so packaged builds have the same deterministic + * required inputs and credit confirmation behavior. + */ +export const FIRECRAWL_WORKFLOW_SETUPS: Record = { + "firecrawl-company-directories": setup("firecrawl-company-directories", [ + field("directory", "Directory URL or name", "text", { required: true, placeholder: "https://example.com/directory" }), + field("filters", "Optional filters", "text", { placeholder: "Region, category, company size" }), + field("resultCap", "Maximum results", "number", { required: true, min: 1, max: 100 }), + field("outputView", "Output view", "select", { required: true, options: OUTPUT_OPTIONS }), + depthField(), + ]), + "firecrawl-competitive-intel": setup("firecrawl-competitive-intel", [ + field("competitors", "Competitors", "text", { required: true, placeholder: "Company A, Company B" }), + field("scope", "Comparison scope", "text", { required: true, placeholder: "Pricing, features, changelog" }), + depthField(), + ]), + "firecrawl-dashboard-reporting": setup("firecrawl-dashboard-reporting", [ + field("dashboardUrl", "Authorized dashboard URL", "url", { required: true }), + field("metrics", "Metric definitions", "text", { required: true }), + field("period", "Reporting period", "text", { required: true, placeholder: "2026 Q2" }), + field("authorized", "I am authorized to access this dashboard", "checkbox", { required: true }), + depthField(), + ]), + "firecrawl-deep-research": setup("firecrawl-deep-research", [ + field("topic", "Research topic", "text", { required: true }), + depthField(true), + field("confirmExhaustive", "Separately confirm the 750-credit exhaustive ceiling", "checkbox", { + required: true, + visibleWhen: { fieldId: "depth", equals: "exhaustive" }, + }), + ]), + "firecrawl-demo-walkthrough": setup("firecrawl-demo-walkthrough", [ + field("url", "Public product URL", "url", { required: true }), + field("flow", "Bounded flow to observe", "text", { required: true }), + depthField(), + ]), + "firecrawl-knowledge-base": setup("firecrawl-knowledge-base", [ + field("sources", "Source URLs", "text", { required: true, placeholder: "One or more public URLs" }), + field("boundary", "Crawl boundary", "text", { required: true, placeholder: "Docs section and page cap" }), + field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }), + depthField(), + ]), + "firecrawl-knowledge-ingest": setup("firecrawl-knowledge-ingest", [ + field("sources", "Source URLs", "text", { required: true }), + field("boundary", "In-app ingest boundary", "text", { required: true }), + field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }), + depthField(), + ]), + "firecrawl-lead-gen": setup("firecrawl-lead-gen", [ + field("target", "Target audience and geography", "text", { required: true }), + field("sourceNote", "Source or authorization note", "text", { required: true }), + field("leadCap", "Maximum leads", "number", { required: true, min: 1, max: 100 }), + field("outputView", "Output view", "select", { required: true, options: OUTPUT_OPTIONS }), + depthField(), + ]), + "firecrawl-lead-research": setup("firecrawl-lead-research", [ + field("company", "Company name or URL", "text", { required: true }), + field("person", "Optional person", "text"), + field("meetingContext", "Meeting context", "text", { required: true }), + depthField(), + ]), + "firecrawl-market-research": setup("firecrawl-market-research", [ + field("market", "Market and geography", "text", { required: true }), + field("period", "Period", "text", { required: true }), + field("question", "Research question", "text", { required: true }), + depthField(), + ]), + "firecrawl-qa": setup("firecrawl-qa", [ + field("targetUrl", "Public target URL", "url", { required: true }), + field("charter", "Bounded test charter", "text", { required: true }), + field("allowedActions", "Allowed read-only actions", "text", { required: true }), + depthField(), + ]), + "firecrawl-research-papers": setup("firecrawl-research-papers", [ + field("topic", "Paper topic", "text", { required: true }), + field("scope", "Date or publication scope", "text", { required: true }), + field("paperCap", "Maximum papers", "number", { required: true, min: 1, max: 50 }), + depthField(), + ]), + "firecrawl-seo-audit": setup("firecrawl-seo-audit", [ + field("siteUrl", "Site URL", "url", { required: true }), + field("boundary", "Crawl boundary", "text", { required: true }), + field("pageCap", "Maximum pages", "number", { required: true, min: 1, max: 100 }), + depthField(), + ]), + "firecrawl-shop": setup("firecrawl-shop", [ + field("product", "Product and constraints", "text", { required: true }), + field("geography", "Shopping geography", "text", { required: true }), + field("resultCap", "Maximum options", "number", { required: true, min: 1, max: 50 }), + depthField(), + ]), + "firecrawl-website-design-clone": setup("firecrawl-website-design-clone", [ + field("targetUrl", "Target page URL", "url", { required: true }), + field("scope", "Pages and design scope", "text", { required: true }), + field("respectRights", "I will respect asset and content rights", "checkbox", { required: true }), + depthField(), + ]), + "firecrawl-workflows": setup("firecrawl-workflows", [ + field("goal", "What do you need?", "text", { required: true }), + field("workflowChoice", "Concrete workflow", "select", { + required: true, + options: FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl" && id !== "firecrawl-workflows").map((id) => ({ label: id.replace(/^firecrawl-/, "").replace(/-/g, " "), value: id })), + }), + depthField(), + ]), + firecrawl: setup("firecrawl", [ + field("operation", "Operation", "select", { + required: true, + options: [ + { label: "Scrape one URL", value: "scrape" }, + { label: "Search the web", value: "search" }, + { label: "Agent research", value: "agent" }, + ], + }), + field("url", "URL", "url", { required: true, visibleWhen: { fieldId: "operation", equals: "scrape" } }), + field("query", "Search query", "text", { required: true, visibleWhen: { fieldId: "operation", equals: "search" } }), + field("task", "Agent task", "text", { required: true, visibleWhen: { fieldId: "operation", equals: "agent" } }), + field("resultCap", "Maximum search results", "number", { required: true, min: 1, max: 10, visibleWhen: { fieldId: "operation", equals: "search" } }), + { ...depthField(true), visibleWhen: { fieldId: "operation", equals: "agent" } }, + ]), +}; + +export function visibleWorkflowFields( + workflowId: FirecrawlWorkflowId, + values: WorkflowSetupValues, +): readonly WorkflowFieldDef[] { + return FIRECRAWL_WORKFLOW_SETUPS[workflowId].fields.filter( + (candidate) => !candidate.visibleWhen || values[candidate.visibleWhen.fieldId] === candidate.visibleWhen.equals, + ); +} + +export function validateWorkflowSetup( + workflowId: FirecrawlWorkflowId, + values: WorkflowSetupValues, +): Record { + const errors: Record = {}; + for (const candidate of visibleWorkflowFields(workflowId, values)) { + const value = values[candidate.id]; + if (candidate.required && (value === undefined || value === "" || value === false)) { + errors[candidate.id] = `${candidate.label} is required`; + continue; + } + if (candidate.type === "url" && typeof value === "string" && value) { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(); + } catch { + errors[candidate.id] = "Enter a valid HTTP(S) URL"; + } + } + if (candidate.type === "number" && value !== undefined && value !== "") { + const numeric = Number(value); + if (!Number.isFinite(numeric) || (candidate.min !== undefined && numeric < candidate.min) || (candidate.max !== undefined && numeric > candidate.max)) { + errors[candidate.id] = `Enter a number from ${candidate.min ?? 0} to ${candidate.max ?? "the allowed maximum"}`; + } + } + } + return errors; +} + +export function setupCreditBudget( + workflowId: FirecrawlWorkflowId, + values: WorkflowSetupValues, +): number { + const depth = values.depth === "thorough" || values.depth === "exhaustive" ? values.depth : "quick"; + return agentPolicy(workflowId, depth)?.maxCredits ?? 0; +} + export function isFirecrawlWorkflow(id: string | undefined): id is FirecrawlWorkflowId { return !!id && Object.prototype.hasOwnProperty.call(FIRECRAWL_WORKFLOWS, id); }