Compare commits

..

1 Commits

Author SHA1 Message Date
732384edba feat: add conversations endpoint 2026-06-17 22:52:19 +01:00
79 changed files with 2094 additions and 5012 deletions

View File

@@ -1,134 +0,0 @@
---
name: upgrading-expo
description: Guidelines for upgrading Expo SDK versions and fixing dependency issues
version: 1.0.0
license: MIT
---
## References
- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)
- ./references/new-architecture.md -- SDK +53: New Architecture migration guide
- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide
- ./references/native-tabs.md -- SDK +55: Native tabs changes (Icon/Label/Badge now accessed via NativeTabs.Trigger.\*)
- ./references/expo-av-to-audio.md -- SDK +55: Migrate audio playback and recording from expo-av to expo-audio
- ./references/expo-av-to-video.md -- SDK +55: Migrate video playback from expo-av to expo-video
- ./references/react-navigation-to-expo-router.md -- SDK +56: Migrate `@react-navigation/*` imports to `expo-router` entry points (codemod + manual mapping)
## Beta/Preview Releases
Beta versions use `.preview` suffix (e.g., `55.0.0-preview.2`), published under `@next` tag.
Check if latest is beta: https://exp.host/--/api/v2/versions (look for `-preview` in `expoVersion`)
```bash
npx expo install expo@next --fix # install beta
```
## Step-by-Step Upgrade Process
1. Upgrade Expo and dependencies
```bash
npx expo install expo@latest
npx expo install --fix
```
2. Run diagnostics: `npx expo-doctor`
3. Clear caches and reinstall
```bash
npx expo export -p ios --clear
rm -rf node_modules .expo
watchman watch-del-all
```
## Breaking Changes Checklist
- Check for removed APIs in release notes
- Update import paths for moved modules
- Review native module changes requiring prebuild
- Test all camera, audio, and video features
- Verify navigation still works correctly
## Prebuild for Native Changes
**First check if `ios/` and `android/` directories exist in the project.** If neither directory exists, the project uses Continuous Native Generation (CNG) and native projects are regenerated at build time — skip this section and "Clear caches for bare workflow" entirely.
If upgrading requires native changes:
```bash
npx expo prebuild --clean
```
This regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.
## Clear caches for bare workflow
These steps only apply when `ios/` and/or `android/` directories exist in the project:
- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`
- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`
- Clear the Gradle cache for Android: `cd android && ./gradlew clean`
## Housekeeping
- Review release notes for the target SDK version at https://expo.dev/changelog
- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.
- Enable React Compiler in SDK 54+ by adding `"experiments": { "reactCompiler": true }` to app.json — it's stable and recommended
- Delete sdkVersion from `app.json` to let Expo manage it automatically
- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.
- If the babel.config.js only contains 'babel-preset-expo', delete the file
- If the metro.config.js only contains expo defaults, delete the file
## Deprecated Packages
| Old Package | Replacement |
| -------------------- | ---------------------------------------------------- |
| `expo-av` | `expo-audio` and `expo-video` |
| `expo-permissions` | Individual package permission APIs |
| `@expo/vector-icons` | `expo-symbols` (for SF Symbols) |
| `AsyncStorage` | `expo-sqlite/localStorage/install` |
| `expo-app-loading` | `expo-splash-screen` |
| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |
When migrating deprecated packages, update all code usage before removing the old package. For expo-av, consult the migration references to convert Audio.Sound to useAudioPlayer, Audio.Recording to useAudioRecorder, and Video components to VideoView with useVideoPlayer.
## expo.install.exclude
Check if package.json has excluded packages:
```json
{
"expo": { "install": { "exclude": ["react-native-reanimated"] } }
}
```
Exclusions are often workarounds that may no longer be needed after upgrading. Review each one.
## Removing patches
Check if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.
## Postcss
- `autoprefixer` isn't needed in SDK +53. Remove it from dependencies and check `postcss.config.js` or `postcss.config.mjs` to remove it from the plugins list.
- Use `postcss.config.mjs` in SDK +53.
## Metro
Remove redundant metro config options:
- resolver.unstable_enablePackageExports is enabled by default in SDK +53.
- `experimentalImportSupport` is enabled by default in SDK +54.
- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.
- cjs and mjs extensions are supported by default in SDK +50.
- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).
## Hermes engine v1
Since SDK 55, users can opt-in to use Hermes engine v1 for improved runtime performance. This requires setting `useHermesV1: true` in the `expo-build-properties` config plugin, and may require a specific version of the `hermes-compiler` npm package. Hermes v1 will become a default in some future SDK release.
## New Architecture
The new architecture is enabled by default, the app.json field `"newArchEnabled": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Upgrading Expo"
short_description: "Upgrade Expo SDKs, fix dependencies, adopt React 19 / React Compiler, and migrate deprecated Expo packages"
default_prompt: "Use $upgrading-expo to upgrade an Expo SDK, run diagnostics, fix dependency conflicts, decide whether prebuild/cache clearing applies, and migrate away from deprecated Expo packages."

View File

@@ -1,132 +0,0 @@
# Migrating from expo-av to expo-audio
## Imports
```tsx
// Before
import { Audio } from 'expo-av';
// After
import { useAudioPlayer, useAudioRecorder, RecordingPresets, AudioModule, setAudioModeAsync } from 'expo-audio';
```
## Audio Playback
### Before (expo-av)
```tsx
const [sound, setSound] = useState<Audio.Sound>();
async function playSound() {
const { sound } = await Audio.Sound.createAsync(require('./audio.mp3'));
setSound(sound);
await sound.playAsync();
}
useEffect(() => {
return sound ? () => { sound.unloadAsync(); } : undefined;
}, [sound]);
```
### After (expo-audio)
```tsx
const player = useAudioPlayer(require('./audio.mp3'));
// Play
player.play();
```
## Audio Recording
### Before (expo-av)
```tsx
const [recording, setRecording] = useState<Audio.Recording>();
async function startRecording() {
await Audio.requestPermissionsAsync();
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const { recording } = await Audio.Recording.createAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
setRecording(recording);
}
async function stopRecording() {
await recording?.stopAndUnloadAsync();
const uri = recording?.getURI();
}
```
### After (expo-audio)
```tsx
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
async function startRecording() {
await AudioModule.requestRecordingPermissionsAsync();
await recorder.prepareToRecordAsync();
recorder.record();
}
async function stopRecording() {
await recorder.stop();
const uri = recorder.uri;
}
```
## Audio Mode
### Before (expo-av)
```tsx
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
});
```
### After (expo-audio)
```tsx
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
interruptionMode: 'doNotMix',
});
```
## API Mapping
| expo-av | expo-audio |
|---------|------------|
| `Audio.Sound.createAsync()` | `useAudioPlayer(source)` |
| `sound.playAsync()` | `player.play()` |
| `sound.pauseAsync()` | `player.pause()` |
| `sound.setPositionAsync(ms)` | `player.seekTo(seconds)` |
| `sound.setVolumeAsync(vol)` | `player.volume = vol` |
| `sound.setRateAsync(rate)` | `player.playbackRate = rate` |
| `sound.setIsLoopingAsync(loop)` | `player.loop = loop` |
| `sound.unloadAsync()` | Automatic via hook |
| `playbackStatus.positionMillis` | `player.currentTime` (seconds) |
| `playbackStatus.durationMillis` | `player.duration` (seconds) |
| `playbackStatus.isPlaying` | `player.playing` |
| `Audio.Recording.createAsync()` | `useAudioRecorder(preset)` |
| `Audio.RecordingOptionsPresets.*` | `RecordingPresets.*` |
| `recording.stopAndUnloadAsync()` | `recorder.stop()` |
| `recording.getURI()` | `recorder.uri` |
| `Audio.requestPermissionsAsync()` | `AudioModule.requestRecordingPermissionsAsync()` |
## Key Differences
- **No auto-reset on finish**: After `play()` completes, the player stays paused at the end. To replay, call `player.seekTo(0)` then `play()`
- **Time in seconds**: expo-audio uses seconds, not milliseconds (matching web standards)
- **Immediate loading**: Audio loads immediately when the hook mounts—no explicit preloading needed
- **Automatic cleanup**: No need to call `unloadAsync()`, hooks handle resource cleanup on unmount
- **Multiple players**: Create multiple `useAudioPlayer` instances and store them—all load immediately
- **Direct property access**: Set volume, rate, loop directly on the player object (`player.volume = 0.5`)
## API Reference
https://docs.expo.dev/versions/latest/sdk/audio/

View File

@@ -1,160 +0,0 @@
# Migrating from expo-av to expo-video
## Imports
```tsx
// Before
import { Video, ResizeMode } from 'expo-av';
// After
import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
import { useEvent, useEventListener } from 'expo';
```
## Video Playback
### Before (expo-av)
```tsx
const videoRef = useRef<Video>(null);
const [status, setStatus] = useState({});
<Video
ref={videoRef}
source={{ uri: 'https://example.com/video.mp4' }}
style={{ width: 350, height: 200 }}
resizeMode={ResizeMode.CONTAIN}
isLooping
onPlaybackStatusUpdate={setStatus}
/>
// Control
videoRef.current?.playAsync();
videoRef.current?.pauseAsync();
```
### After (expo-video)
```tsx
const player = useVideoPlayer('https://example.com/video.mp4', player => {
player.loop = true;
});
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
<VideoView
player={player}
style={{ width: 350, height: 200 }}
contentFit="contain"
/>
// Control
player.play();
player.pause();
```
## Status Updates
### Before (expo-av)
```tsx
<Video
onPlaybackStatusUpdate={status => {
if (status.isLoaded) {
console.log(status.positionMillis, status.durationMillis, status.isPlaying);
if (status.didJustFinish) console.log('finished');
}
}}
/>
```
### After (expo-video)
```tsx
// Reactive state
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
// Side effects
useEventListener(player, 'playToEnd', () => console.log('finished'));
// Direct access
console.log(player.currentTime, player.duration, player.playing);
```
## Local Files
### Before (expo-av)
```tsx
<Video source={require('./video.mp4')} />
```
### After (expo-video)
```tsx
const player = useVideoPlayer({ assetId: require('./video.mp4') });
```
## Fullscreen and PiP
```tsx
<VideoView
player={player}
allowsFullscreen
allowsPictureInPicture
onFullscreenEnter={() => {}}
onFullscreenExit={() => {}}
/>
```
For PiP and background playback, add to app.json:
```json
{
"expo": {
"plugins": [
["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }]
]
}
}
```
## API Mapping
| expo-av | expo-video |
|---------|------------|
| `<Video>` | `<VideoView>` |
| `ref={videoRef}` | `player={useVideoPlayer()}` |
| `source={{ uri }}` | Pass to `useVideoPlayer(uri)` |
| `resizeMode={ResizeMode.CONTAIN}` | `contentFit="contain"` |
| `isLooping` | `player.loop = true` |
| `shouldPlay` | `player.play()` in setup |
| `isMuted` | `player.muted = true` |
| `useNativeControls` | `nativeControls={true}` |
| `onPlaybackStatusUpdate` | `useEvent` / `useEventListener` |
| `videoRef.current.playAsync()` | `player.play()` |
| `videoRef.current.pauseAsync()` | `player.pause()` |
| `videoRef.current.replayAsync()` | `player.replay()` |
| `videoRef.current.setPositionAsync(ms)` | `player.currentTime = seconds` |
| `status.positionMillis` | `player.currentTime` (seconds) |
| `status.durationMillis` | `player.duration` (seconds) |
| `status.didJustFinish` | `useEventListener(player, 'playToEnd')` |
## Key Differences
- **Separate player and view**: Player logic decoupled from the view—one player can be used across multiple views
- **Time in seconds**: Uses seconds, not milliseconds
- **Event system**: Uses `useEvent`/`useEventListener` from `expo` instead of callback props
- **Video preloading**: Create a player without mounting a VideoView to preload for faster transitions
- **Built-in caching**: Set `useCaching: true` in VideoSource for persistent offline caching
## Known Issues
- **Uninstall expo-av first**: On Android, having both expo-av and expo-video installed can cause VideoView to show a black screen. Uninstall expo-av before installing expo-video
- **Android: Reusing players**: Mounting the same player in multiple VideoViews simultaneously can cause black screens on Android (works on iOS)
- **Android: currentTime in setup**: Setting `player.currentTime` in the `useVideoPlayer` setup callback may not work on Android—set it after mount instead
- **Changing source**: Use `player.replace(newSource)` to change videos without recreating the player
## API Reference
https://docs.expo.dev/versions/latest/sdk/video/

View File

@@ -1,124 +0,0 @@
# Native Tabs Migration (SDK 55)
In SDK 55, `Label`, `Icon`, `Badge`, and `VectorIcon` are now accessed as static properties on `NativeTabs.Trigger` rather than separate imports.
## Import Changes
```tsx
// SDK 53/54
import {
NativeTabs,
Icon,
Label,
Badge,
VectorIcon,
} from "expo-router/unstable-native-tabs";
// SDK 55+
import { NativeTabs } from "expo-router/unstable-native-tabs";
```
## Component Changes
| SDK 53/54 | SDK 55+ |
| ---------------- | ----------------------------------- |
| `<Icon />` | `<NativeTabs.Trigger.Icon />` |
| `<Label />` | `<NativeTabs.Trigger.Label />` |
| `<Badge />` | `<NativeTabs.Trigger.Badge />` |
| `<VectorIcon />` | `<NativeTabs.Trigger.VectorIcon />` |
| (n/a) | `<NativeTabs.BottomAccessory />` |
## New in SDK 55
### BottomAccessory
New component for Apple Music-style mini players on iOS +26 that float above the tab bar:
```tsx
<NativeTabs>
<NativeTabs.BottomAccessory>
{/* Content above tabs */}
</NativeTabs.BottomAccessory>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
```
On Android and web, this component will render as a no-op. Position a view absolutely above the tab bar instead.
### Icon `md` Prop
New `md` prop for Material icon glyphs on Android (alongside existing `drawable`):
```tsx
<NativeTabs.Trigger.Icon sf="house" md="home" />
```
## Full Migration Example
### Before (SDK 53/54)
```tsx
import {
NativeTabs,
Icon,
Label,
Badge,
} from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="(index)">
<Label>Home</Label>
<Icon sf="house.fill" />
<Badge>3</Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<Label>Settings</Label>
<Icon sf="gear" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
### After (SDK 55+)
```tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
## Migration Checklist
1. Remove `Icon`, `Label`, `Badge`, `VectorIcon` from imports
2. Keep only `NativeTabs` import from `expo-router/unstable-native-tabs`
3. Replace `<Icon />` with `<NativeTabs.Trigger.Icon />`
4. Replace `<Label />` with `<NativeTabs.Trigger.Label />`
5. Replace `<Badge />` with `<NativeTabs.Trigger.Badge />`
6. Replace `<VectorIcon />` with `<NativeTabs.Trigger.VectorIcon />`
- Read docs for more info https://docs.expo.dev/versions/v55.0.0/sdk/router-native-tabs/

View File

@@ -1,79 +0,0 @@
# New Architecture
The New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.
## Documentation
Full guide: https://docs.expo.dev/guides/new-architecture/
## What Changed
- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native
- **Fabric** — New rendering system with concurrent features
- **TurboModules** — Lazy-loaded native modules with type safety
## SDK Compatibility
| SDK Version | New Architecture Status |
| ----------- | ----------------------- |
| SDK 53+ | Enabled by default |
| SDK 52 | Opt-in via app.json |
| SDK 51- | Experimental |
## Configuration
New Architecture is enabled by default. To explicitly disable (not recommended):
```json
{
"expo": {
"newArchEnabled": false
}
}
```
## Expo Go
Expo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.
## Common Migration Issues
### Native Module Compatibility
Some older native modules may not support the New Architecture. Check:
1. Module documentation for New Architecture support
2. GitHub issues for compatibility discussions
3. Consider alternatives if module is unmaintained
### Reanimated
React Native Reanimated requires `react-native-worklets` in SDK 54+:
```bash
npx expo install react-native-worklets
```
### Layout Animations
Some layout animations behave differently. Test thoroughly after upgrading.
## Verifying New Architecture
Check if New Architecture is active:
```tsx
import { Platform } from "react-native";
// Returns true if Fabric is enabled
const isNewArch = global._IS_FABRIC !== undefined;
```
Verify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval "_IS_FABRIC"` -> `true`
## Troubleshooting
1. **Clear caches**`npx expo start --clear`
2. **Clean prebuild**`npx expo prebuild --clean`
3. **Check native modules** — Ensure all dependencies support New Architecture
4. **Review console warnings** — Legacy modules log compatibility warnings

View File

@@ -1,79 +0,0 @@
# React 19
React 19 is included in Expo SDK 54. This release simplifies several common patterns.
## Context Changes
### useContext → use
The `use` hook replaces `useContext`:
```tsx
// Before (React 18)
import { useContext } from "react";
const value = useContext(MyContext);
// After (React 19)
import { use } from "react";
const value = use(MyContext);
```
- The `use` hook can also read promises, enabling Suspense-based data fetching.
- `use` can be called conditionally, this simplifies components that consume multiple contexts.
### Context.Provider → Context
Context providers no longer need the `.Provider` suffix:
```tsx
// Before (React 18)
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
// After (React 19)
<ThemeContext value={theme}>
{children}
</ThemeContext>
```
## ref as a Prop
### Removing forwardRef
Components can now receive `ref` as a regular prop. `forwardRef` is no longer needed:
```tsx
// Before (React 18)
import { forwardRef } from "react";
const Input = forwardRef<TextInput, Props>((props, ref) => {
return <TextInput ref={ref} {...props} />;
});
// After (React 19)
function Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />;
}
```
### Migration Steps
1. Remove `forwardRef` wrapper
2. Add `ref` to the props destructuring
3. Update the type to include `ref?: React.Ref<T>`
## Other React 19 Features
- **Actions** — Functions that handle async transitions
- **useOptimistic** — Optimistic UI updates
- **useFormStatus** — Form submission state (web)
- **Document Metadata** — Native `<title>` and `<meta>` support (web)
## Cleanup Checklist
When upgrading to SDK 54:
- [ ] Replace `useContext` with `use`
- [ ] Remove `.Provider` from Context components
- [ ] Remove `forwardRef` wrappers, use `ref` prop instead

View File

@@ -1,59 +0,0 @@
# React Compiler
React Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.
## Enabling React Compiler
Add to `app.json`:
```json
{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}
```
## What React Compiler Does
- Automatically memoizes components and values
- Eliminates unnecessary re-renders
- Removes the need for manual `useMemo` and `useCallback`
- Works with existing code without modifications
## Cleanup After Enabling
Once React Compiler is enabled, you can remove manual memoization:
```tsx
// Before (manual memoization)
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a), [a]);
const MemoizedComponent = React.memo(MyComponent);
// After (React Compiler handles it)
const value = computeExpensive(a, b);
const callback = () => doSomething(a);
// Just use MyComponent directly
```
## Requirements
- Expo SDK 54 or later
- New Architecture enabled (default in SDK 54+)
## Verifying It's Working
React Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.
## Troubleshooting
If you encounter issues:
1. Ensure New Architecture is enabled
2. Clear Metro cache: `npx expo start --clear`
3. Check for incompatible patterns in your code (rare)
React Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.

View File

@@ -1,61 +0,0 @@
# Migrating from react-navigation to expo-router
In SDK 56+, application code must not import from `@react-navigation/*` directly. Repoint those imports to the matching `expo-router` entry points. Runtime API is unchanged — only the module specifiers move.
## Steps
1. Prefer the automated codemod (see below). If it is not viable, fall back to the manual mapping.
2. Replace imports using the table. Use the entry point that matches the `@react-navigation/*` source.
3. After rewriting, check whether any of the rewritten imports are deprecated in `expo-router` (see [Check for deprecated imports](#check-for-deprecated-imports)). If so, surface the deprecation reason and the suggested replacement to the user before continuing.
4. Validate: search for remaining `@react-navigation/` references in source files, then run typecheck/build/start.
5. Remove `@react-navigation/*` packages that are no longer imported from `package.json` and reinstall (delete `node_modules` if needed).
## Automated migration (preferred)
Run from the project root over your application code (replace `src` with the actual directory or glob):
```sh
npx expo-codemod sdk-56-expo-router-react-navigation-replace src
```
```sh
npx expo-codemod sdk-56-expo-router-react-navigation-replace '**/*.{ts,tsx,js,jsx}'
```
## Manual API mapping
| React Navigation source | Expo Router target |
| ------------------------------------- | ------------------------------------------------------------------------ |
| `@react-navigation/native` | `expo-router/react-navigation` |
| `@react-navigation/core` | `expo-router/react-navigation` |
| `@react-navigation/elements` | `expo-router/react-navigation` |
| `@react-navigation/routers` | `expo-router/react-navigation` |
| `@react-navigation/stack` | `expo-router/js-stack` |
| `@react-navigation/bottom-tabs` | `expo-router/js-tabs` |
| `@react-navigation/material-top-tabs` | `expo-router/js-top-tabs` |
| `@react-navigation/native-stack` | No direct equivalent. Use the `Stack` layout from `expo-router` instead. |
**Stack caveat:** Do NOT rewrite `import { Stack } from "expo-router"` to `expo-router/js-stack`. The root `Stack` is the Expo Router layout component used in route files; only use `expo-router/js-stack` when replacing a `@react-navigation/stack` JS stack navigator.
If you encounter a symbol that has no replacement, ask the user to file an issue in the `expo/expo` repository describing what is needed and why.
## Check for deprecated imports
A successful rewrite to `expo-router/*` does not guarantee the new import is the recommended one. Some symbols are re-exported as deprecated shims and the project may need to migrate further (for example, to a different `expo-router` API or to a first-party Expo package).
For each symbol rewritten in step 2:
1. Resolve the rewritten module to its source in `node_modules` (e.g., `node_modules/expo-router/build/react-navigation.d.ts`, `js-stack`, `js-tabs`, `js-top-tabs`).
2. Look for a `@deprecated` JSDoc tag on the named export, or a runtime deprecation warning in the implementation file.
3. If deprecated, capture both the reason and the recommended replacement from the JSDoc/comment.
4. Report each deprecated symbol to the user with: the import path, the symbol, the deprecation reason, and the suggested replacement. Wait for the user to confirm before mass-applying further changes.
## Done when
1. No `@react-navigation/*` imports remain in source files.
2. No unused `@react-navigation/*` entries remain in `package.json`.
3. Typecheck and bundler start without `@react-navigation/*` errors.
## Reference
- Official Expo Router SDK 55 → 56 migration guide: https://docs.expo.dev/router/migrate/sdk-55-to-56

View File

@@ -1,20 +0,0 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"languages": {
"TypeScript": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"TSX": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"JavaScript": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"JSX": {
"formatter": { "language_server": { "name": "oxfmt" } }
}
}
}

View File

@@ -39,13 +39,4 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- Commits: conventional commit format, title <= 50 chars
## Nix
Use the Nix dev shell for project commands by default.
- Run repo tooling through `nix develop -c`, e.g. `nix develop -c bun test`.
- Use Bun exclusively inside the Nix shell.
- Do not use host `bun`, `node`, `tsc`, or package binaries for project tasks unless explicitly checking host behavior.
- Simple inspection commands like `rg`, `sed`, `ls`, and `git status` may run outside Nix.
- While `flake.nix` is untracked, use `nix develop path:. -c <command>`.
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`

View File

@@ -21,8 +21,8 @@
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",

View File

@@ -7,9 +7,5 @@
"format": "oxfmt --write .",
"start": "bun run src/agent-test-cli.ts",
"typecheck": "bun tsc --noEmit"
},
"dependencies": {
"@freya/agent-protocol": "workspace:*",
"@nym.sh/jrpc": "^0.1.0"
}
}

View File

@@ -1,13 +1,3 @@
import type {
AgentClientApi,
AgentEvent,
AgentServerApi,
SendMessageResult,
} from "@freya/agent-protocol"
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
type JsonObject = Record<string, unknown>
interface AuthUser {
@@ -25,6 +15,10 @@ interface AuthSession {
}
}
interface QueryResponse {
message: string
}
interface QueryToolDefinition {
name: string
label: string
@@ -66,219 +60,6 @@ class CookieJar {
}
}
class AgentWebSocketSession implements AgentClientApi {
private readonly channel: WebSocketJrpcChannel
private readonly client: JsonRpcClient<AgentServerApi>
private readonly server: JsonRpcServer<AgentClientApi>
private conversationId: string | undefined
private responseHadText = false
private constructor(channel: WebSocketJrpcChannel) {
this.channel = channel
this.client = new JsonRpcClient<AgentServerApi>(channel)
this.server = new JsonRpcServer<AgentClientApi>(
{
notify: this.notify.bind(this),
},
channel,
)
}
static async connect(backendUrl: string, cookies: CookieJar): Promise<AgentWebSocketSession> {
const channel = new WebSocketJrpcChannel(agentWebSocketUrl(backendUrl), cookies.header())
const session = new AgentWebSocketSession(channel)
try {
await channel.waitUntilOpen()
void session.server.start().catch((err: unknown) => {
if (!channel.isClosed()) {
console.error(`\nWebSocket JSON-RPC failed: ${formatError(err)}\n`)
}
})
await session.client.call("ping")
} catch (err) {
channel.close()
throw err
}
return session
}
async ask(message: string): Promise<void> {
this.responseHadText = false
const result = await this.sendMessage(message)
if (result.conversationId) {
this.conversationId = result.conversationId
}
if (!this.responseHadText) {
console.log(`\nagent> ${result.message || "(no message)"}`)
}
console.log("")
}
notify(event: AgentEvent): void {
switch (event.type) {
case "conversation_started":
this.conversationId = event.conversationId
break
case "message_created":
this.printMessage(event.text)
break
case "tool_started":
console.log(`\ntool> ${event.toolName} started`)
break
case "tool_finished":
console.log(`tool> ${event.toolName} ${event.ok ? "finished" : "failed"}`)
break
case "message_finished":
break
case "message_failed":
console.log(`\nagent! ${event.error}`)
break
}
}
describeConversation(): string {
return this.conversationId ? `Conversation: ${this.conversationId}` : "No conversation yet."
}
close(): void {
this.channel.close()
}
private async sendMessage(message: string): Promise<SendMessageResult> {
return this.client.call("sendMessage", message)
}
private printMessage(text: string): void {
if (text === "") return
console.log(`\nagent> ${text}`)
this.responseHadText = true
}
}
class WebSocketJrpcChannel implements JrpcChannel {
private readonly ws: WebSocket
private readonly opened: Promise<void>
private closed = false
private openedOnce = false
private queue: JrpcMessage[] = []
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
constructor(url: string, cookieHeader?: string) {
this.ws = new WebSocket(url, createWebSocketOptions(cookieHeader))
this.opened = new Promise((resolve, reject) => {
this.ws.onopen = () => {
this.openedOnce = true
resolve()
}
this.ws.onerror = () => {
if (!this.openedOnce) {
reject(new Error(`Could not connect to ${url}`))
}
}
this.ws.onclose = (event) => {
if (!this.openedOnce) {
reject(new Error(formatWebSocketClose(url, event)))
}
this.close()
}
})
this.ws.onmessage = (event) => {
this.receive(event.data)
}
}
waitUntilOpen(): Promise<void> {
return this.opened
}
isClosed(): boolean {
return this.closed
}
async send(msg: JsonRpcMessage): Promise<void> {
await this.opened
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("JSON-RPC WebSocket channel is closed")
}
this.ws.send(JSON.stringify(msg))
}
async next(): Promise<IteratorResult<JrpcMessage, void>> {
const msg = this.queue.shift()
if (msg) {
return { done: false, value: msg }
}
if (this.closed) {
return { done: true, value: undefined }
}
return new Promise((resolve) => {
this.waiters.push(resolve)
})
}
async return(): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
return { done: true, value: undefined }
}
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
throw error
}
async [Symbol.asyncDispose](): Promise<void> {
await this.return()
}
close(): void {
if (this.closed) return
this.closed = true
for (const resolve of this.waiters.splice(0)) {
resolve({ done: true, value: undefined })
}
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
this.ws.close()
}
}
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
return this
}
private receive(message: unknown): void {
const parsed = parseJrpcMessage(message)
if (!parsed) {
this.ws.close(1003, "Invalid JSON-RPC message")
this.close()
return
}
this.push(parsed)
}
private push(msg: JrpcMessage): void {
if (this.closed) return
const resolve = this.waiters.shift()
if (resolve) {
resolve({ done: false, value: msg })
return
}
this.queue.push(msg)
}
}
async function main(): Promise<void> {
if (wantsHelp()) {
printUsage()
@@ -330,11 +111,8 @@ async function runChatLoop(
cookies: CookieJar,
session: AuthSession,
): Promise<void> {
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
console.log("Connected to /api/agent/ws")
printHelp()
try {
for (;;) {
const input = askOptional("you> ")?.trim()
if (!input) continue
@@ -354,20 +132,13 @@ async function runChatLoop(
continue
}
if (input === "/conversation") {
console.log(agent.describeConversation())
continue
}
if (input === "/tools") {
await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue
}
if (input.startsWith("/tool ")) {
await runCliCommand(() =>
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
)
await runCliCommand(() => executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)))
continue
}
@@ -386,14 +157,25 @@ async function runChatLoop(
}
try {
await agent.ask(input)
await askAgent(backendUrl, cookies, input)
} catch (err) {
console.error(`\n${formatError(err)}\n`)
}
}
} finally {
agent.close()
}
async function askAgent(backendUrl: string, cookies: CookieJar, message: string): Promise<void> {
const data = await requestJson(backendUrl, cookies, "/api/agent", {
method: "POST",
body: { message },
})
if (!isQueryResponse(data)) {
throw new Error("Query returned an unexpected response shape")
}
console.log(`\nagent> ${data.message || "(no message)"}`)
console.log("")
}
async function runCliCommand(command: () => Promise<void>): Promise<void> {
@@ -545,7 +327,7 @@ async function requestJson(
function printIntro(): void {
console.log("FREYA agent test CLI")
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n")
console.log("Connect to a backend, sign in, then send test messages to /api/agent.\n")
}
function printUsage(): void {
@@ -566,7 +348,6 @@ function printHelp(): void {
console.log(" /tool Execute an agent debug tool with JSON params")
console.log(" /actions List source actions: /actions <source-id>")
console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>")
console.log(" /conversation Show the current websocket conversation")
console.log(" /session Show the signed-in user")
console.log(" /help Show commands")
console.log(" /quit Exit\n")
@@ -636,33 +417,6 @@ function normalizeBackendUrl(value: string): string {
}
}
function agentWebSocketUrl(backendUrl: string): string {
const url = new URL(backendUrl)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = "/api/agent/ws"
url.search = ""
url.hash = ""
return url.toString()
}
function createWebSocketOptions(cookieHeader?: string): Bun.WebSocketOptions | undefined {
if (!cookieHeader) return undefined
return {
headers: {
Cookie: cookieHeader,
},
}
}
function formatWebSocketClose(
url: string,
event: { code: number; reason: string; wasClean: boolean },
): string {
const reason = event.reason ? `: ${event.reason}` : ""
return `Could not connect to ${url} (${event.code}${reason})`
}
function formatPromptLabel(label: string, defaultValue?: string): string {
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
}
@@ -757,25 +511,6 @@ function splitSetCookieHeader(header: string): string[] {
return parts.filter(Boolean)
}
function parseJrpcMessage(message: unknown): JrpcMessage | null {
const text = webSocketMessageText(message)
if (!text) return null
try {
const value: unknown = JSON.parse(text)
return isJrpcMessage(value) ? value : null
} catch {
return null
}
}
function webSocketMessageText(message: unknown): string | null {
if (typeof message === "string") return message
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8")
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
return null
}
async function readResponseError(response: Response, path: string): Promise<string> {
const text = await response.text()
if (response.status === 404 && path === "/api/agent") {
@@ -813,6 +548,11 @@ function isAuthSession(value: unknown): value is AuthSession {
)
}
function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false
return typeof value.message === "string"
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
return value.tools.every(isQueryToolDefinition)
@@ -845,33 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri
)
}
function isJrpcMessage(value: unknown): value is JrpcMessage {
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.id !== "number") {
return false
}
if ("method" in value) {
return (
typeof value.method === "string" &&
(value.params === undefined || Array.isArray(value.params))
)
}
if ("result" in value) {
return true
}
if ("error" in value) {
return isJsonRpcErrorObject(value.error)
}
return false
}
function isJsonRpcErrorObject(value: unknown): boolean {
return isJsonObject(value) && typeof value.code === "number" && typeof value.message === "string"
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View File

@@ -15,10 +15,7 @@
"create-admin": "bun run src/scripts/create-admin.ts"
},
"dependencies": {
"@better-auth/core": "^1.6.20",
"@better-auth/expo": "^1.6.20",
"@earendil-works/pi-coding-agent": "^0.79.1",
"@freya/agent-protocol": "workspace:*",
"@freya/core": "workspace:*",
"@freya/source-caldav": "workspace:*",
"@freya/source-google-calendar": "workspace:*",
@@ -28,10 +25,9 @@
"@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*",
"@freya/source-web-search": "workspace:*",
"@nym.sh/jrpc": "^0.1.0",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1.6.20",
"better-auth": "^1",
"drizzle-orm": "^0.45.1",
"hono": "^4",
"lodash.merge": "^4.6.2",

View File

@@ -1,4 +1,3 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
@@ -7,6 +6,7 @@ import type {
ConversationStorageEntry,
} from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { ConversationRecordingQueryAgent } from "./conversation-recording-query-agent.ts"
import {
createQueryAgentEventListeners,

View File

@@ -1,13 +1,12 @@
import type { ConversationEntryMetadata } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { randomUUID } from "node:crypto"
import type {
AppendConversationEntryInput,
ConversationEntryRow,
} from "../conversations/storage.ts"
import type { ConversationEntryMetadata } from "../conversations/types.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
@@ -20,7 +19,6 @@ import {
type QueryAgentStreamEvent,
} from "./query-agent.ts"
/** Storage operations used to persist and replay query-agent conversation entries. */
export interface ConversationStorage {
getOrCreateConversation(): Promise<{ id: string }>
appendEntry(
@@ -30,13 +28,11 @@ export interface ConversationStorage {
listEntries(conversationId: string): Promise<ConversationStorageEntry[]>
}
/** Minimal persisted entry shape needed by recording and replay agents. */
export type ConversationStorageEntry = Pick<
ConversationEntryRow,
"id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt"
>
/** Configuration for wrapping a QueryAgent with conversation recording. */
export interface ConversationRecordingQueryAgentConfig {
agent: QueryAgent
storage: ConversationStorage

View File

@@ -1,9 +1,9 @@
import { ConversationEntryKind } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgentStreamEvent } from "./query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession {

View File

@@ -33,25 +33,13 @@ import {
import { createSessionManager } from "./session-manager.ts"
import { createFreyaAgentTools, FREYA_AGENT_TOOL_NAMES } from "./tools.ts"
/** Active Pi SDK session instance returned by createAgentSession. */
type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"]
/** Pi event emitted when a message finishes. */
type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>
/** Message payload carried by Pi's message-end event. */
type PiAgentMessage = PiMessageEndEvent["message"]
/** Pi event emitted when an agent run finishes. */
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
/** Session manager created for Pi conversation replay. */
type PiSessionManager = ReturnType<typeof createSessionManager>
/** Message shape accepted by the replay session manager. */
type PiSessionMessage = Parameters<PiSessionManager["appendMessage"]>[0]
/** Configuration for the Pi-backed query agent. */
export interface PiQueryAgentConfig {
toolbox: QueryAgentToolbox
apiKey?: string

View File

@@ -1,8 +1,8 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { createSessionManager } from "./session-manager.ts"
describe("createSessionManager", () => {

View File

@@ -1,21 +1,18 @@
import { SessionManager } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import {
AssistantMessagePayload,
ContextSummaryPayload,
ConversationEntryKind,
UserMessagePayload,
} from "@freya/core"
import { tmpdir } from "node:os"
} from "../conversations/types.ts"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
/** Message shape accepted by Pi's SessionManager.appendMessage API. */
type PiMessage = Parameters<SessionManager["appendMessage"]>[0]
/** Assistant message variant required when replaying stored assistant entries. */
type PiAssistantMessage = Extract<PiMessage, { role: "assistant" }>
/** Inputs required to rebuild a Pi session manager from stored conversation entries. */
export interface CreateSessionManagerInput {
cwd?: string
entries: ConversationStorageEntry[]

View File

@@ -1,149 +0,0 @@
import type { AgentEvent } from "@freya/agent-protocol"
import { describe, expect, test } from "bun:test"
import type {
QueryAgent,
QueryAgentAsk,
QueryAgentEvent,
QueryAgentEventListener,
QueryAgentStreamEvent,
} from "./query-agent.ts"
import type { AgentResponseStreamItem } from "./streaming.ts"
import { streamAgentResponse } from "./streaming.ts"
class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentStreamEvent[]
constructor(events: QueryAgentStreamEvent[]) {
this.events = events
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
this.inputs.push(input)
for (const event of this.events) {
yield event
}
}
addEventListener<T extends QueryAgentEvent>(
_type: T,
_listener: QueryAgentEventListener<T>,
): () => void {
return () => {}
}
dispose(): void {}
}
describe("streamAgentResponse", () => {
test("emits one message event per completed newline", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: "First message\nSec" },
{ type: "text_delta", text: "ond message\nThird message" },
{ type: "done" },
])
const { events, result } = await collectStreamAgentResponse(
streamAgentResponse({
agent,
input: { message: "hello" },
}),
)
expect(result).toEqual({
conversationId: "conversation-1",
message: "First message\nSecond message\nThird message",
})
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: "First message" },
{ type: "message_created", text: "Second message" },
{ type: "message_created", text: "Third message" },
{ type: "message_finished" },
])
})
test("preserves whitespace without emitting empty message events", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: " const value = 1 \n\n return value" },
{ type: "done" },
])
const { events, result } = await collectStreamAgentResponse(
streamAgentResponse({
agent,
input: { message: "hello" },
}),
)
expect(result).toEqual({
conversationId: "conversation-1",
message: " const value = 1 \n\n return value",
})
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: " const value = 1 " },
{ type: "message_created", text: " return value" },
{ type: "message_finished" },
])
})
test("emits tool and failure events", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: "I'll check" },
{ type: "tool_start", toolName: "calendar" },
{ type: "tool_end", toolName: "calendar", ok: false },
{ type: "text_delta", text: "That failed" },
{ type: "error", message: "model unavailable" },
])
const stream = streamAgentResponse({
agent,
input: { message: "hello" },
})
const events: AgentEvent[] = []
await expect(collectStreamAgentResponse(stream, events)).rejects.toThrow("model unavailable")
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: "I'll check" },
{ type: "tool_started", toolName: "calendar" },
{ type: "tool_finished", toolName: "calendar", ok: false },
{ type: "message_created", text: "That failed" },
{ type: "message_failed", error: "model unavailable" },
])
})
})
async function collectStreamAgentResponse(
stream: AsyncIterable<AgentResponseStreamItem>,
events: AgentEvent[] = [],
): Promise<{
events: AgentEvent[]
result: { message: string; conversationId: string }
}> {
let result: { message: string; conversationId: string } | null = null
for await (const item of stream) {
switch (item.type) {
case "event":
events.push(item.event)
break
case "result":
result = item.result
break
}
}
if (!result) {
throw new Error("Expected stream result")
}
return { events, result }
}

View File

@@ -1,125 +0,0 @@
import type { AgentEvent, SendMessageResult } from "@freya/agent-protocol"
import type { QueryAgent, QueryAgentAsk } from "./query-agent.ts"
export type AgentResponseStreamItem =
| { type: "event"; event: AgentEvent }
| { type: "result"; result: SendMessageResult }
export async function* streamAgentResponse({
agent,
input,
}: {
agent: QueryAgent
input: QueryAgentAsk
}): AsyncGenerator<AgentResponseStreamItem, void, void> {
let message = ""
let conversationId: string | null = null
const splitter = new AgentMessageSplitter()
function messageEvent(text: string): AgentResponseStreamItem | null {
if (text.trim() === "") return null
return { type: "event", event: { type: "message_created", text } }
}
function flushPendingMessage(): AgentResponseStreamItem | null {
const text = splitter.flush()
if (text === null) return null
return messageEvent(text)
}
for await (const event of agent.ask(input)) {
switch (event.type) {
case "conversation":
conversationId = event.conversationId
yield { type: "event", event: { type: "conversation_started", conversationId } }
break
case "text_delta":
message += event.text
for (const line of splitter.push(event.text)) {
const item = messageEvent(line)
if (item) yield item
}
break
case "tool_start":
{
const item = flushPendingMessage()
if (item) yield item
}
yield { type: "event", event: { type: "tool_started", toolName: event.toolName } }
break
case "tool_end":
{
const item = flushPendingMessage()
if (item) yield item
}
yield {
type: "event",
event: {
type: "tool_finished",
toolName: event.toolName,
ok: event.ok,
},
}
break
case "error":
{
const item = flushPendingMessage()
if (item) yield item
}
yield { type: "event", event: { type: "message_failed", error: event.message } }
throw new Error(event.message)
case "done":
{
const item = flushPendingMessage()
if (item) yield item
}
const result = createResult(message, conversationId)
yield { type: "event", event: { type: "message_finished" } }
yield { type: "result", result }
return
}
}
const item = flushPendingMessage()
if (item) yield item
const result = createResult(message, conversationId)
yield { type: "event", event: { type: "message_finished" } }
yield { type: "result", result }
}
function createResult(message: string, conversationId: string | null): SendMessageResult {
if (!conversationId) {
throw new Error("Agent response stream ended without a conversation id")
}
return { message, conversationId }
}
class AgentMessageSplitter {
private pending = ""
push(text: string): string[] {
this.pending += text
const lines = this.pending.split(/\r?\n/)
this.pending = lines.pop() ?? ""
return lines
}
flush(): string | null {
if (this.pending === "") return null
const text = this.pending
this.pending = ""
return text
}
}

View File

@@ -1,68 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import { registerAgentWebSocketHandlers } from "./ws.ts"
describe("agent websocket handler", () => {
test("rejects disallowed browser origins before authenticating", async () => {
let sessionChecked = false
const app = new Hono()
registerAgentWebSocketHandlers(app, {
sessionManager: {} as UserSessionManager,
corsMiddleware: async (c, next) => {
const origin = c.req.header("origin")
if (origin && origin !== "https://app.freya.test") {
return c.text("Forbidden", 403)
}
await next()
},
authSessionMiddleware: async (c) => {
sessionChecked = true
return c.json({ error: "Unauthorized" }, 401)
},
})
const res = await app.fetch(
new Request("https://api.freya.test/api/agent/ws", {
headers: {
origin: "https://evil.test",
upgrade: "websocket",
},
}),
)
expect(res.status).toBe(403)
expect(sessionChecked).toBe(false)
})
test("allows requests without an origin header", async () => {
let sessionChecked = false
const app = new Hono()
registerAgentWebSocketHandlers(app, {
sessionManager: {} as UserSessionManager,
corsMiddleware: async (_c, next) => {
await next()
},
authSessionMiddleware: async (c) => {
sessionChecked = true
return c.json({ error: "Unauthorized" }, 401)
},
})
const res = await app.fetch(
new Request("https://api.freya.test/api/agent/ws", {
headers: {
upgrade: "websocket",
},
}),
)
expect(res.status).toBe(401)
expect(sessionChecked).toBe(true)
})
})

View File

@@ -1,287 +0,0 @@
import type { AgentClientApi, AgentServerApi, SendMessageResult } from "@freya/agent-protocol"
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
import type { Hono, MiddlewareHandler } from "hono"
import type { WSContext } from "hono/ws"
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
import { type } from "arktype"
import { upgradeWebSocket, websocket } from "hono/bun"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
import { streamAgentResponse } from "./streaming.ts"
interface AgentWebSocketHandlerDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
corsMiddleware: MiddlewareHandler
}
interface ValidSendMessageInput {
message: string
}
export const agentWebSocket = websocket
const SendMessageInputBody = type({
"+": "reject",
message: "string",
})
export function registerAgentWebSocketHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware, corsMiddleware }: AgentWebSocketHandlerDeps,
): void {
app.get(
"/api/agent/ws",
corsMiddleware,
authSessionMiddleware,
upgradeWebSocket((c) => {
const user = c.get("user")
if (!user) {
throw new Error("Authenticated WebSocket user missing")
}
const channel = new HonoWebSocketJrpcChannel()
const connection = new AgentRpcConnection({
channel,
sessionManager,
userId: user.id,
})
return {
onOpen(_event, ws) {
channel.attach(ws)
void connection.start().catch((err: unknown) => {
console.error("[query] Agent WebSocket JSON-RPC failed:", errorMessage(err))
ws.close(1011, "Agent RPC connection failed")
})
},
onMessage(event) {
channel.receive(event.data)
},
onClose() {
channel.close()
},
}
}),
)
}
class AgentRpcConnection implements AgentServerApi {
private readonly client: JsonRpcClient<AgentClientApi>
private readonly server: JsonRpcServer<AgentServerApi>
private activeMessage: Promise<SendMessageResult> | null = null
private readonly sessionManager: UserSessionManager
private readonly userId: string
constructor({
channel,
sessionManager,
userId,
}: {
channel: JrpcChannel
sessionManager: UserSessionManager
userId: string
}) {
this.sessionManager = sessionManager
this.userId = userId
this.client = new JsonRpcClient<AgentClientApi>(channel)
this.server = new JsonRpcServer<AgentServerApi>(
{
sendMessage: this.sendMessage.bind(this),
ping: this.ping.bind(this),
},
channel,
)
}
start(): Promise<void> {
return this.server.start()
}
async sendMessage(message: string): Promise<SendMessageResult> {
const parsed = SendMessageInputBody({ message })
if (parsed instanceof type.errors) {
throw new Error(parsed.summary)
}
if (this.activeMessage) {
throw new Error("A message is already running")
}
const run = this.runMessage(parsed)
this.activeMessage = run
try {
return await run
} finally {
if (this.activeMessage === run) {
this.activeMessage = null
}
}
}
ping(): "pong" {
return "pong"
}
private async runMessage(input: ValidSendMessageInput): Promise<SendMessageResult> {
const session = await this.sessionManager.getOrCreate(this.userId)
let result: SendMessageResult | null = null
for await (const item of streamAgentResponse({ agent: session.agent, input })) {
switch (item.type) {
case "event":
await this.client.call("notify", item.event)
break
case "result":
result = item.result
break
}
}
if (!result) {
throw new Error("Agent response stream ended without a result")
}
return result
}
}
class HonoWebSocketJrpcChannel implements JrpcChannel {
private closed = false
private queue: JrpcMessage[] = []
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
private ws: WSContext | null = null
attach(ws: WSContext): void {
this.ws = ws
}
async send(msg: JsonRpcMessage): Promise<void> {
if (this.closed || !this.ws) {
throw new Error("JSON-RPC WebSocket channel is closed")
}
this.ws.send(JSON.stringify(msg))
}
receive(message: unknown): void {
const parsed = parseJrpcMessage(message)
if (!parsed) {
this.ws?.close(1003, "Invalid JSON-RPC message")
return
}
this.push(parsed)
}
async next(): Promise<IteratorResult<JrpcMessage, void>> {
const msg = this.queue.shift()
if (msg) {
return { done: false, value: msg }
}
if (this.closed) {
return { done: true, value: undefined }
}
return new Promise((resolve) => {
this.waiters.push(resolve)
})
}
async return(): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
this.ws?.close()
return { done: true, value: undefined }
}
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
throw error
}
async [Symbol.asyncDispose](): Promise<void> {
await this.return()
}
close(): void {
if (this.closed) return
this.closed = true
for (const resolve of this.waiters.splice(0)) {
resolve({ done: true, value: undefined })
}
}
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
return this
}
private push(msg: JrpcMessage): void {
if (this.closed) return
const resolve = this.waiters.shift()
if (resolve) {
resolve({ done: false, value: msg })
return
}
this.queue.push(msg)
}
}
function parseJrpcMessage(message: unknown): JrpcMessage | null {
const text = webSocketMessageText(message)
if (text === null) return null
try {
const value: unknown = JSON.parse(text)
return isJrpcMessage(value) ? value : null
} catch {
return null
}
}
function webSocketMessageText(message: unknown): string | null {
if (typeof message === "string") return message
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
if (ArrayBuffer.isView(message)) {
return Buffer.from(message.buffer, message.byteOffset, message.byteLength).toString("utf8")
}
return null
}
function isJrpcMessage(value: unknown): value is JrpcMessage {
if (typeof value !== "object" || value === null) return false
if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") return false
if ("method" in value) {
return "id" in value && typeof value.id === "number" && typeof value.method === "string"
}
if ("result" in value) {
return "id" in value && typeof value.id === "number"
}
if ("error" in value) {
return (
"id" in value &&
typeof value.id === "number" &&
typeof value.error === "object" &&
value.error !== null
)
}
return false
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View File

@@ -1,4 +1,3 @@
import { expo } from "@better-auth/expo"
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { admin } from "better-auth/plugins"
@@ -33,7 +32,7 @@ export function createAuth(db: Database) {
},
},
},
plugins: [admin(), expo()],
plugins: [admin()],
})
}

View File

@@ -53,6 +53,16 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
}
}
/**
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
*/
export function createGetSessionFromHeaders(auth: Auth) {
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
const session = await auth.api.getSession({ headers })
return session
}
}
/**
* Dev/test middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401.

View File

@@ -1,11 +0,0 @@
export class ConversationNotFoundError extends Error {
readonly conversationId: string
readonly userId: string
constructor(conversationId: string, userId: string) {
super(`Conversation "${conversationId}" not found for user "${userId}"`)
this.name = "ConversationNotFoundError"
this.conversationId = conversationId
this.userId = userId
}
}

View File

@@ -1,55 +1,21 @@
import { ConversationEntryKind, ConversationEntryVisibility } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import type {
ConversationEntryRow,
ConversationRow,
ListConversationEntriesParams,
} from "./storage.ts"
import type { ConversationRow } from "./storage.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { registerConversationsHttpHandlers } from "./http.ts"
const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
const ConversationId = "11111111-1111-4111-8111-111111111111"
const MissingConversationId = "22222222-2222-4222-8222-222222222222"
const conversationRowsByUser = new Map<string, ConversationRow[]>()
const conversationEntryRowsByUserAndConversation = new Map<string, ConversationEntryRow[]>()
const listEntriesCalls: Array<{
userId: string
conversationId: string
params: ListConversationEntriesParams
}> = []
mock.module("./storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async listConversations(): Promise<ConversationRow[]> {
return conversationRowsByUser.get(userId) ?? []
},
async listEntries(
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
listEntriesCalls.push({ userId, conversationId, params })
const rows = conversationEntryRowsByUserAndConversation.get(
conversationEntriesKey(userId, conversationId),
)
if (!rows) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) {
return rows.filter((row) => row.visibility === params.visibility)
}
return rows
},
}),
}))
@@ -78,39 +44,9 @@ function createConversationRow(
}
}
function createConversationEntryRow(
id: string,
conversationId: string,
sequence: number,
kind: ConversationEntryRow["kind"],
visibility: ConversationEntryRow["visibility"],
payload: ConversationEntryRow["payload"],
createdAt: string,
metadata: ConversationEntryRow["metadata"] = {},
fileId: string | null = null,
): ConversationEntryRow {
return {
id,
conversationId,
sequence,
kind,
visibility,
fileId,
payload,
metadata,
createdAt: new Date(createdAt),
}
}
function conversationEntriesKey(userId: string, conversationId: string): string {
return `${userId}:${conversationId}`
}
describe("GET /api/conversations", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
test("returns 401 without auth", async () => {
@@ -172,162 +108,3 @@ describe("GET /api/conversations", () => {
})
})
})
describe("GET /api/conversations/:id/entries", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
test("returns 401 without auth", async () => {
const app = buildTestApp()
const res = await app.request("/api/conversations/conversation-1/entries")
expect(res.status).toBe(401)
})
test("returns user-visible entries for the authenticated user", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-user",
ConversationId,
1,
ConversationEntryKind.UserMessage,
ConversationEntryVisibility.UserVisible,
{
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
"2026-06-17T09:30:00.000Z",
),
createConversationEntryRow(
"entry-tool",
ConversationId,
2,
ConversationEntryKind.ToolCall,
ConversationEntryVisibility.Internal,
{
toolName: "freya_list_context",
input: {},
},
"2026-06-17T09:30:01.000Z",
),
createConversationEntryRow(
"entry-assistant",
ConversationId,
3,
ConversationEntryKind.AssistantMessage,
ConversationEntryVisibility.UserVisible,
{
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
"2026-06-17T09:30:02.000Z",
{ runId: "run-1" },
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: ConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({
entries: [
{
id: "entry-user",
conversationId: ConversationId,
sequence: 1,
kind: ConversationEntryKind.UserMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
metadata: {},
createdAt: "2026-06-17T09:30:00.000Z",
},
{
id: "entry-assistant",
conversationId: ConversationId,
sequence: 3,
kind: ConversationEntryKind.AssistantMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
metadata: { runId: "run-1" },
createdAt: "2026-06-17T09:30:02.000Z",
},
],
})
})
test("returns an empty list when the conversation has no user-visible entries", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-tool",
ConversationId,
1,
ConversationEntryKind.ToolResult,
ConversationEntryVisibility.Internal,
{ toolCallId: "call-1", output: { ok: true } },
"2026-06-17T09:30:00.000Z",
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({ entries: [] })
})
test("returns 404 for malformed conversation ids without querying storage", async () => {
const app = buildTestApp("user-1")
const res = await app.request("/api/conversations/missing-conversation/entries")
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
test("returns 404 when the conversation does not exist for the user", async () => {
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${MissingConversationId}/entries`)
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: MissingConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
})

View File

@@ -1,38 +1,23 @@
import type { Context, Hono } from "hono"
import { ConversationEntryVisibility } from "@freya/core"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { conversations } from "./storage.ts"
/** Hono environment populated by the conversations route middleware. */
type Env = {
Variables: {
db: Database
}
}
/** Serialized conversation summary returned by the list endpoint. */
interface ConversationSummaryResponse {
id: string
createdAt: string
updatedAt: string
}
/** Dependencies required to register conversation HTTP handlers. */
interface ConversationsHttpHandlersDeps {
db: Database
authSessionMiddleware: AuthSessionMiddleware
}
const ConversationIdParam = type("string.uuid")
export function registerConversationsHttpHandlers(
app: Hono,
{ db, authSessionMiddleware }: ConversationsHttpHandlersDeps,
@@ -43,7 +28,6 @@ export function registerConversationsHttpHandlers(
})
app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations)
app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries)
}
async function handleListConversations(c: Context<Env>) {
@@ -51,54 +35,10 @@ async function handleListConversations(c: Context<Env>) {
const db = c.get("db")
return c.json({
conversations: (await conversations(db, user.id).listConversations()).map(
serializeConversation,
),
})
}
async function handleListEntries(c: Context<Env>) {
const user = c.get("user")!
const db = c.get("db")
const conversationId = c.req.param("id")
if (!conversationId) {
return c.json({ error: "Conversation not found" }, 404)
}
const parsedConversationId = ConversationIdParam(conversationId)
if (parsedConversationId instanceof type.errors) {
return c.json({ error: "Conversation not found" }, 404)
}
try {
const entries = await conversations(db, user.id).listEntries(parsedConversationId, {
visibility: ConversationEntryVisibility.UserVisible,
})
return c.json({
entries: entries.map((row) => ({
id: row.id,
conversationId: row.conversationId,
sequence: row.sequence,
kind: row.kind,
visibility: row.visibility,
fileId: row.fileId,
payload: row.payload,
metadata: row.metadata,
createdAt: row.createdAt.toISOString(),
})),
})
} catch (err) {
if (err instanceof ConversationNotFoundError) {
return c.json({ error: "Conversation not found" }, 404)
}
throw err
}
}
function serializeConversation(row: ConversationRow): ConversationSummaryResponse {
return {
conversations: (await conversations(db, user.id).listConversations()).map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
})),
})
}

View File

@@ -1,18 +1,17 @@
import {
AssistantMessagePayload,
AttachmentPayload,
ConversationEntryKind,
ConversationEntryVisibility,
ContextSummaryPayload,
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
type ConversationEntryPayload,
} from "@freya/core"
import { type } from "arktype"
import { and, asc, desc, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts"
import type {
AssistantMessagePayload,
AttachmentPayload,
ContextSummaryPayload,
ConversationEntryKind as ConversationEntryKindType,
ConversationEntryMetadata,
ConversationEntryPayload,
ConversationEntryVisibility as ConversationEntryVisibilityType,
GenericObjectPayload,
UserMessagePayload,
} from "./types.ts"
import {
conversationEntries,
@@ -20,21 +19,23 @@ import {
files,
user,
} from "../db/schema.ts"
import { ConversationNotFoundError } from "./errors.ts"
import {
ConversationEntryMetadata as ConversationEntryMetadataSchema,
AssistantMessagePayload as AssistantMessagePayloadSchema,
AttachmentPayload as AttachmentPayloadSchema,
ConversationEntryKind,
ConversationEntryKindInput,
ConversationEntryVisibility,
ConversationEntryVisibilityInput,
ContextSummaryPayload as ContextSummaryPayloadSchema,
GenericObjectPayload as GenericObjectPayloadSchema,
UserMessagePayload as UserMessagePayloadSchema,
} from "./types.ts"
const conversationEntryKind = type.enumerated(...Object.values(ConversationEntryKind))
const conversationEntryVisibility = type.enumerated(...Object.values(ConversationEntryVisibility))
/** Database row shape for a conversation owned by a user. */
export type ConversationRow = typeof conversationsTable.$inferSelect
/** Database row shape for an entry in a conversation timeline. */
export type ConversationEntryRow = typeof conversationEntries.$inferSelect
/** Database row shape for an uploaded file referenced by conversations. */
export type FileRow = typeof files.$inferSelect
/** Input required to create a stored file record. */
export interface CreateFileInput {
storageKey: string
originalName?: string
@@ -43,27 +44,23 @@ export interface CreateFileInput {
metadata?: Record<string, unknown>
}
/** Input for creating a file and appending its attachment entry together. */
export interface AppendAttachmentEntryInput {
file: CreateFileInput
payload: AttachmentPayload
visibility?: ConversationEntryVisibility
visibility?: ConversationEntryVisibilityType
metadata?: ConversationEntryMetadata
}
/** Result returned after a file-backed attachment entry is appended. */
export interface AppendAttachmentEntryResult {
file: FileRow
entry: ConversationEntryRow
}
/** Common fields accepted when appending any conversation entry. */
interface AppendConversationEntryBase {
visibility?: ConversationEntryVisibility
visibility?: ConversationEntryVisibilityType
metadata?: ConversationEntryMetadata
}
/** Discriminated input for appending any supported entry kind to a conversation. */
export type AppendConversationEntryInput =
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.UserMessage
@@ -94,13 +91,12 @@ export type AppendConversationEntryInput =
fileId?: never
})
/** Filters accepted when listing conversation entries. */
export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibility
visibility?: ConversationEntryVisibilityType
}
export function conversations(db: Database, userId: string) {
const storage = {
return {
async createConversation(): Promise<ConversationRow> {
return insertConversation(db, userId)
},
@@ -113,18 +109,6 @@ export function conversations(db: Database, userId: string) {
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
},
async getConversation(conversationId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(
and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)),
)
.limit(1)
return rows[0] ?? null
},
async getOrCreateConversation(): Promise<ConversationRow> {
return db.transaction(async (tx) => {
await requireUserForUpdate(tx, userId)
@@ -143,12 +127,12 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationEntryRow> {
const kind = conversationEntryKind.assert(input.kind)
const visibility = conversationEntryVisibility.assert(
const kind = ConversationEntryKindInput.assert(input.kind)
const visibility = ConversationEntryVisibilityInput.assert(
input.visibility ?? defaultVisibilityForKind(kind),
)
const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
let fileId: string | null = null
if (input.kind === ConversationEntryKind.Attachment) {
@@ -157,9 +141,7 @@ export function conversations(db: Database, userId: string) {
}
const rows = await db.transaction(async (tx) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
await requireConversationForUpdate(tx, userId, conversationId)
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
@@ -186,16 +168,14 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
input: AppendAttachmentEntryInput,
): Promise<AppendAttachmentEntryResult> {
const payload = AttachmentPayload.assert(input.payload)
const visibility = conversationEntryVisibility.assert(
const payload = AttachmentPayloadSchema.assert(input.payload)
const visibility = ConversationEntryVisibilityInput.assert(
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
)
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
return db.transaction(async (tx) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
await requireConversationForUpdate(tx, userId, conversationId)
const file = await insertFile(tx, userId, input.file)
const sequence = await nextSequence(tx, conversationId)
@@ -224,9 +204,7 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
await requireConversation(db, userId, conversationId)
if (params.visibility) {
return db
@@ -248,27 +226,25 @@ export function conversations(db: Database, userId: string) {
.orderBy(asc(conversationEntries.sequence))
},
}
return storage
}
function payloadForKind(
kind: ConversationEntryKind,
kind: ConversationEntryKindType,
payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload {
switch (kind) {
case ConversationEntryKind.UserMessage:
return UserMessagePayload.assert(payload)
return UserMessagePayloadSchema.assert(payload)
case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayload.assert(payload)
return AssistantMessagePayloadSchema.assert(payload)
case ConversationEntryKind.Attachment:
return AttachmentPayload.assert(payload)
return AttachmentPayloadSchema.assert(payload)
case ConversationEntryKind.ContextSummary:
return ContextSummaryPayload.assert(payload)
return ContextSummaryPayloadSchema.assert(payload)
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote:
return GenericObjectPayload.assert(payload)
return GenericObjectPayloadSchema.assert(payload)
}
}
@@ -283,11 +259,25 @@ async function requireUserForUpdate(db: Database, userId: string): Promise<void>
requireRow(rows, `User not found: ${userId}`)
}
async function findConversationForUpdate(
async function requireConversation(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow | null> {
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function requireConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
@@ -295,7 +285,7 @@ async function findConversationForUpdate(
.limit(1)
.for("update")
return rows[0] ?? null
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {
@@ -374,7 +364,9 @@ function requireRow<T>(rows: T[], message = "Expected database row"): T {
return row
}
function defaultVisibilityForKind(kind: ConversationEntryKind): ConversationEntryVisibility {
function defaultVisibilityForKind(
kind: ConversationEntryKindType,
): ConversationEntryVisibilityType {
switch (kind) {
case ConversationEntryKind.UserMessage:
case ConversationEntryKind.AssistantMessage:

View File

@@ -7,7 +7,7 @@ import {
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
} from "./conversation"
} from "./types.ts"
describe("conversation entry schemas", () => {
test("parses valid user message payloads", () => {

View File

@@ -1,6 +1,5 @@
import { type } from "arktype"
/** Entry kinds supported by the persisted conversation timeline. */
export const ConversationEntryKind = {
UserMessage: "user_message",
AssistantMessage: "assistant_message",
@@ -11,21 +10,17 @@ export const ConversationEntryKind = {
SystemNote: "system_note",
} as const
/** Discriminator for the payload shape and handling of a conversation entry. */
export type ConversationEntryKind =
(typeof ConversationEntryKind)[keyof typeof ConversationEntryKind]
/** Visibility scopes supported by stored conversation entries. */
export const ConversationEntryVisibility = {
UserVisible: "user_visible",
Internal: "internal",
} as const
/** Indicates whether a conversation entry should be exposed to the user. */
export type ConversationEntryVisibility =
(typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility]
/** Attachment media categories accepted by conversation entries. */
export const AttachmentType = {
Image: "image",
Audio: "audio",
@@ -34,64 +29,57 @@ export const AttachmentType = {
Other: "other",
} as const
/** File or media category associated with an attachment payload. */
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
/** Plain text content part for a message. */
export const TextMessagePart = type({
export const ConversationEntryKindInput = type.enumerated(...Object.values(ConversationEntryKind))
export const ConversationEntryVisibilityInput = type.enumerated(
...Object.values(ConversationEntryVisibility),
)
export const AttachmentTypeInput = type.enumerated(...Object.values(AttachmentType))
const TextMessagePart = type({
"+": "reject",
type: "'text'",
text: "string",
})
/** Structured JSON content part for a message. */
export const JsonMessagePart = type({
const JsonMessagePart = type({
"+": "reject",
type: "'json'",
value: "unknown",
})
/** Content part variants supported by user and assistant messages. */
export const MessagePart = type.or(TextMessagePart, JsonMessagePart)
/** A structured content part inside a user or assistant message payload. */
export type MessagePart = typeof MessagePart.infer
/** User-authored message entry payload. */
export const UserMessagePayload = type({
"+": "reject",
role: "'user'",
parts: MessagePart.array().atLeastLength(1),
})
/** Payload stored for a conversation entry containing a user message. */
export type UserMessagePayload = typeof UserMessagePayload.infer
/** Assistant-authored message entry payload. */
export const AssistantMessagePayload = type({
"+": "reject",
role: "'assistant'",
parts: MessagePart.array().atLeastLength(1),
})
/** Payload stored for a conversation entry containing an assistant message. */
export type AssistantMessagePayload = typeof AssistantMessagePayload.infer
/** Attachment entry payload. */
export const AttachmentPayload = type({
"+": "reject",
role: type.enumerated("user", "assistant"),
name: "string",
mimeType: "string",
attachmentType: type.enumerated(...Object.values(AttachmentType)),
attachmentType: AttachmentTypeInput,
"caption?": "string",
})
/** Payload stored for a conversation entry that references an uploaded file. */
export type AttachmentPayload = typeof AttachmentPayload.infer
/** Durable facts extracted from compacted conversation history. */
export const ContextSummary = type({
const ContextSummary = type({
"+": "reject",
"userIntent?": "string",
durableFacts: type.string.array(),
@@ -101,10 +89,6 @@ export const ContextSummary = type({
importantDetails: type.string.array(),
})
/** Durable facts and follow-ups retained from compacted conversation history. */
export type ContextSummary = typeof ContextSummary.infer
/** Context-summary conversation entry payload. */
export const ContextSummaryPayload = type({
"+": "reject",
covers: type({
@@ -117,10 +101,8 @@ export const ContextSummaryPayload = type({
"sourceEntryIds?": type.string.array(),
})
/** Payload describing a compaction summary and the sequence range it covers. */
export type ContextSummaryPayload = typeof ContextSummaryPayload.infer
/** Model invocation metadata recorded on generated entries. */
export const ModelRunMetadata = type({
"+": "reject",
route: "string",
@@ -134,33 +116,18 @@ export const ModelRunMetadata = type({
"providerRequestId?": "string",
})
/** Metadata describing the model run that produced a conversation entry. */
export type ModelRunMetadata = typeof ModelRunMetadata.infer
/** Arbitrary metadata stored alongside conversation entries. */
export const ConversationEntryMetadata = type({
"modelRun?": ModelRunMetadata,
"[string]": "unknown",
})
/** Metadata bag attached to a conversation entry. */
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer
/** Generic object payload used by operational entries. */
export const GenericObjectPayload = type("Record<string, unknown>")
/** Fallback payload shape for tool calls, tool results, and system notes. */
export type GenericObjectPayload = typeof GenericObjectPayload.infer
export const ConversationEntryPayload = type.or(
UserMessagePayload,
AssistantMessagePayload,
AttachmentPayload,
ContextSummaryPayload,
GenericObjectPayload,
)
/** Union of payload shapes that can be stored on a conversation entry. */
export type ConversationEntryPayload =
| UserMessagePayload
| AssistantMessagePayload

View File

@@ -1,10 +1,3 @@
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "@freya/core"
import { sql } from "drizzle-orm"
import {
boolean,
@@ -20,6 +13,14 @@ import {
uuid,
} from "drizzle-orm/pg-core"
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "../conversations/types.ts"
// ---------------------------------------------------------------------------
// Better Auth core tables
// Re-exported from CLI-generated schema.

View File

@@ -1,11 +1,9 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { createMiddleware } from "hono/factory"
import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createQueryDebugTools } from "./agent/debug-tools.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./agent/http.ts"
import { agentWebSocket, registerAgentWebSocketHandlers } from "./agent/ws.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
@@ -84,15 +82,6 @@ function main() {
return allowedOrigins.includes(origin) ? origin : undefined
}
const agentWebSocketCorsMiddleware = createMiddleware(async (c, next) => {
const origin = c.req.header("origin")
if (origin && resolveOrigin(origin) === undefined) {
return c.text("Forbidden", 403)
}
await next()
})
app.use(
"/api/auth/*",
cors({
@@ -120,6 +109,7 @@ function main() {
registerAuthHandlers(app, auth)
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware,
@@ -130,7 +120,6 @@ function main() {
sessionManager,
authSessionMiddleware,
})
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
if (isDebugMode) {
registerDebugAgentHttpHandlers(app, {
authSessionMiddleware,
@@ -140,12 +129,6 @@ function main() {
}
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
registerAgentWebSocketHandlers(app, {
sessionManager,
authSessionMiddleware,
corsMiddleware: agentWebSocketCorsMiddleware,
})
process.on("SIGTERM", async () => {
sessionManager.dispose()
await closeDb()
@@ -161,5 +144,4 @@ export default {
port: 3000,
hostname: "0.0.0.0",
fetch: app.fetch,
websocket: agentWebSocket,
}

View File

@@ -1,6 +1,5 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location"
import { WeatherSource } from "@freya/source-weatherkit"
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
@@ -10,6 +9,7 @@ import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { CredentialEncryptor } from "../lib/crypto.ts"
import {
CredentialStorageUnavailableError,

View File

@@ -1,6 +1,5 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location"
import { describe, expect, spyOn, test } from "bun:test"
@@ -10,6 +9,7 @@ import type {
} from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {

View File

@@ -1,12 +1,13 @@
{
"expo": {
"name": "Freya",
"slug": "freya",
"slug": "freya-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "freya",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
@@ -23,6 +24,7 @@
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.freya"
},
@@ -52,82 +54,55 @@
{
"fontFamily": "Inter",
"fontDefinitions": [
{
"path": "./assets/fonts/Inter_100Thin.ttf",
"weight": 100
},
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{
"path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_200ExtraLight.ttf",
"weight": 200
},
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{
"path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_300Light.ttf",
"weight": 300
},
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{
"path": "./assets/fonts/Inter_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_400Regular.ttf",
"weight": 400
},
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{
"path": "./assets/fonts/Inter_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_500Medium.ttf",
"weight": 500
},
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{
"path": "./assets/fonts/Inter_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_600SemiBold.ttf",
"weight": 600
},
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{
"path": "./assets/fonts/Inter_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_700Bold.ttf",
"weight": 700
},
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{
"path": "./assets/fonts/Inter_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_800ExtraBold.ttf",
"weight": 800
},
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{
"path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{
"path": "./assets/fonts/Inter_900Black.ttf",
"weight": 900
},
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{
"path": "./assets/fonts/Inter_900Black_Italic.ttf",
"weight": 900,
@@ -138,73 +113,49 @@
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"weight": 200
},
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_300Light.ttf",
"weight": 300
},
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{
"path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_400Regular.ttf",
"weight": 400
},
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{
"path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_500Medium.ttf",
"weight": 500
},
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{
"path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_600SemiBold.ttf",
"weight": 600
},
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{
"path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_700Bold.ttf",
"weight": 700
},
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{
"path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"weight": 800
},
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{
"path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{
"path": "./assets/fonts/SourceSerif4_900Black.ttf",
"weight": 900
},
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{
"path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf",
"weight": 900,
@@ -253,10 +204,7 @@
]
}
}
],
"expo-web-browser",
"expo-image",
"expo-secure-store"
]
],
"experiments": {
"typedRoutes": true,
@@ -265,7 +213,7 @@
"extra": {
"router": {},
"eas": {
"projectId": "c54ea4e5-27da-4066-b081-db8005ecf70a"
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
}
}
}

View File

@@ -6,16 +6,12 @@
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"image": "sdk-56"
}
"distribution": "internal"
},
"development-simulator": {
"extends": "development",
"ios": {
"image": "sdk-56",
"simulator": true
"simulator": "true"
}
},
"preview": {

View File

@@ -10,63 +10,51 @@
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive",
"build:ios-simulator": "bunx eas-cli build --profile development-simulator --platform ios --non-interactive",
"build:ios": "eas build --profile development --platform ios --non-interactive",
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts"
},
"dependencies": {
"@better-auth/core": "^1.6.20",
"@better-auth/expo": "^1.6.20",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@freya/core": "workspace:*",
"@json-render/react-native": "^0.13.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@shopify/flash-list": "2.0.2",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"arktype": "^2.2.1",
"better-auth": "^1.6.20",
"class-variance-authority": "^0.7.1",
"expo": "^56.0.0",
"expo-blur": "~56.0.3",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-font": "~56.0.7",
"expo-glass-effect": "~0.1.10",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.11",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.14",
"expo-location": "~56.0.18",
"expo-network": "~56.0.5",
"expo-router": "~56.2.11",
"expo-secure-store": "~56.0.4",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"jotai": "^2.20.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-easing-gradient": "^1.1.1",
"react-native-gesture-handler": "~2.31.1",
"react-native-keyboard-controller": "1.21.6",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "~19.2.10",
"@types/react": "~19.1.0",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~56.0.4",
"typescript": "~6.0.3"
"eslint-config-expo": "~10.0.0",
"typescript": "^6"
}
}

View File

@@ -8,23 +8,19 @@ import type { ServerWebSocket } from "bun"
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0"
const METRO_HOST = process.env.METRO_HOST || "localhost"
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://${METRO_HOST}:${METRO_PORT}`
const METRO_WS_BASE = `ws://${METRO_HOST}:${METRO_PORT}`
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers)
result.delete("origin")
result.delete("referer")
result.set("host", `${METRO_HOST}:${METRO_PORT}`)
result.set("host", `127.0.0.1:${METRO_PORT}`)
return result
}
interface WsData {
upstream: WebSocket
upstreamUrl: string
path: string
isDevice: boolean
}
@@ -44,10 +40,8 @@ Bun.serve<WsData>({
// WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const path = `${url.pathname}${url.search}`
const wsUrl = `${METRO_WS_BASE}${path}`
console.log(`[proxy] ws connecting ${path}`)
const upstream = connectUpstreamWebSocket(wsUrl, getWebSocketHeaders(req, url))
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client
try {
@@ -60,7 +54,7 @@ Bun.serve<WsData>({
}
const isDevice = url.pathname.startsWith("/inspector/device")
const ok = server.upgrade(req, { data: { upstream, upstreamUrl: wsUrl, path, isDevice } })
const ok = server.upgrade(req, { data: { upstream, isDevice } })
if (!ok) {
upstream.close()
return new Response("WebSocket upgrade failed", { status: 500 })
@@ -71,12 +65,12 @@ Bun.serve<WsData>({
// HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const body = req.body ? await req.arrayBuffer() : undefined
const res = await fetchUpstream(upstream, req.method, forwardHeaders(req.headers), body)
if (res == null) {
return new Response(`Metro is not reachable on ${METRO_HOST}. Restart the Expo dev server.`, {
status: 502,
const res = await fetch(upstream, {
method: req.method,
headers: forwardHeaders(req.headers),
body,
redirect: "manual",
})
}
return new Response(res.body, {
status: res.status,
@@ -87,28 +81,19 @@ Bun.serve<WsData>({
websocket: {
message(ws: ServerWebSocket<WsData>, msg) {
sendUpstream(ws.data.upstream, msg)
ws.data.upstream.send(msg)
},
open(ws: ServerWebSocket<WsData>) {
const { upstream } = ws.data
console.log(`[proxy] ws open ${ws.data.path}`)
upstream.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
ws.send(ev.data)
} else if (ev.data instanceof ArrayBuffer) {
ws.sendBinary(new Uint8Array(ev.data))
} else if (ev.data instanceof Blob) {
ev.data.arrayBuffer().then((buffer) => ws.sendBinary(new Uint8Array(buffer)))
}
})
upstream.addEventListener("close", (ev) => {
console.log(`[proxy] upstream close ${ws.data.path} code=${ev.code} reason=${ev.reason}`)
ws.close(ev.code, ev.reason)
})
upstream.addEventListener("error", () => {
console.error(`[proxy] upstream error ${ws.data.upstreamUrl}`)
ws.close()
})
upstream.addEventListener("close", () => ws.close())
upstream.addEventListener("error", () => ws.close())
// Print debugger URL shortly after a device connects,
// giving Metro time to register the target.
@@ -117,7 +102,6 @@ Bun.serve<WsData>({
}
},
close(ws: ServerWebSocket<WsData>) {
console.log(`[proxy] client close ${ws.data.path}`)
ws.data.upstream.close()
},
},
@@ -134,10 +118,12 @@ async function printDebuggerUrl() {
if (!Array.isArray(parsedTargets)) return
const targets = parsedTargets.filter(isDebugTarget)
const target = targets.find(prefersFuseboxFrontend) ?? targets[0]
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return
const wsPath = getProxyWebSocketPath(target.webSocketDebuggerUrl)
const wsPath = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
@@ -145,51 +131,9 @@ async function printDebuggerUrl() {
}
console.log(
`[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to ${METRO_HOST}:${METRO_PORT}`,
`[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`,
)
async function fetchUpstream(
upstream: string,
method: string,
headers: Headers,
body: ArrayBuffer | undefined,
) {
try {
return await fetch(upstream, {
method,
headers,
body,
redirect: "manual",
})
} catch {
console.error(`[proxy] ${method} ${upstream} failed; Metro is not reachable`)
return null
}
}
function sendUpstream(upstream: WebSocket, msg: string | Buffer) {
if (typeof msg === "string") {
upstream.send(msg)
return
}
upstream.send(new Uint8Array(msg))
}
function getWebSocketHeaders(req: Request, url: URL) {
return {
Origin: req.headers.get("origin") ?? url.origin,
}
}
function connectUpstreamWebSocket(url: string, headers: Record<string, string>) {
const BunWebSocket = WebSocket as unknown as {
new (url: string, options: { headers: Record<string, string> }): WebSocket
}
return new BunWebSocket(url, { headers })
}
function isDebugTarget(value: unknown): value is DebugTarget {
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
@@ -205,15 +149,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
}
function prefersFuseboxFrontend(target: DebugTarget) {
return target.reactNative?.capabilities?.prefersFuseboxFrontend === true
}
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -4,6 +4,7 @@
import { $ } from "bun"
const PROXY_PORT = process.env.PROXY_PORT || "8080"
const METRO_PORT = process.env.METRO_PORT || "8081"
const tsIp = (await $`tailscale ip -4`.text()).trim()
const base = `http://${tsIp}:${PROXY_PORT}`
@@ -29,14 +30,16 @@ if (!Array.isArray(parsedTargets)) {
}
const targets = parsedTargets.filter(isDebugTarget)
const target = targets.find(prefersFuseboxFrontend) ?? targets[0]
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) {
console.error("No debug target found. Is the app connected?")
process.exit(1)
}
const wsUrl = getProxyWebSocketPath(target.webSocketDebuggerUrl)
const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
@@ -68,15 +71,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
}
function prefersFuseboxFrontend(target: DebugTarget) {
return target.reactNative?.capabilities?.prefersFuseboxFrontend === true
}
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -1,47 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
PROXY_PORT=${PROXY_PORT:-8080}
METRO_HOST=${METRO_HOST:-localhost}
METRO_PORT=${METRO_PORT:-8081}
PROXY_PORT=8080
METRO_PORT=8081
TS_IP=$(tailscale ip -4)
port_is_open() {
(: >"/dev/tcp/$1/$2") >/dev/null 2>&1
}
ensure_port_available() {
local port=$1
local name=$2
if port_is_open localhost "$port"; then
echo "$name port $port is already in use." >&2
echo "Stop the existing process or set ${name}_PORT to another value." >&2
exit 1
fi
}
wait_for_metro() {
for _ in {1..120}; do
if port_is_open "$METRO_HOST" "$METRO_PORT"; then
return 0
fi
sleep 0.5
done
echo "Metro did not start on ${METRO_HOST}:${METRO_PORT}." >&2
return 1
}
ensure_port_available "$PROXY_PORT" PROXY
ensure_port_available "$METRO_PORT" METRO
# Start the proxy only after Metro is listening. Otherwise an iOS client can hit
# the proxy during Expo startup and get a misleading upstream connection error.
(
wait_for_metro
exec env PROXY_PORT=$PROXY_PORT METRO_HOST=$METRO_HOST METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts
) &
# Start a reverse proxy so Metro sees all requests as loopback.
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
# accessible through the Tailscale IP.
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
PROXY_PID=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT

View File

@@ -0,0 +1,6 @@
import { ApiRequestMiddleware } from "./client"
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
// TODO: placeholder auth middleware
return init
}

View File

@@ -27,12 +27,9 @@ export class ApiClient {
(prevInit, middleware) => middleware(url, prevInit),
init,
)
return fetch(this.baseUrl ? this.baseUrl + url : url, finalInit)
.then((res) => Promise.all([Promise.resolve(res), res.json()]))
.catch((err) => {
console.log(`request error: ${url}`, err)
throw err
})
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then(
(res) => Promise.all([Promise.resolve(res), res.json()]),
)
}
}

View File

@@ -1,134 +0,0 @@
import { Link } from "expo-router"
import { Pressable, ScrollView, View } from "react-native"
import tw from "twrnc"
import { FeedCard } from "@/components/ui/feed-card"
import { MonospaceText } from "@/components/ui/monospace-text"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
import { ChatOverlay } from "@/conversations/chat-overlay"
export default function HomeScreen() {
return (
<View style={tw`bg-stone-100 dark:bg-stone-950 flex-1 relative`}>
<ScrollView
contentContainerStyle={tw`px-5 pt-6 pb-28 gap-3 justify-end flex-grow`}
showsVerticalScrollIndicator={false}
>
<FeedCard style={tw`bg-white dark:bg-stone-900 p-4 gap-3`}>
<View style={tw`flex-row items-center justify-between gap-3`}>
<SansSerifText style={tw`text-xs text-stone-500 dark:text-stone-400 uppercase`}>
Morning brief
</SansSerifText>
<MonospaceText style={tw`text-xs text-teal-700 dark:text-teal-300`}>
08:42
</MonospaceText>
</View>
<SerifText style={tw`text-3xl leading-9`}>
A calm start with two useful windows.
</SerifText>
<SansSerifText style={tw`text-base leading-6 text-stone-600 dark:text-stone-300`}>
Your morning is light until the project sync. Rain holds off until late afternoon, and
your last note suggests starting with the proposal outline.
</SansSerifText>
<View style={tw`flex-row flex-wrap gap-2`}>
<View style={tw`rounded-full bg-teal-50 dark:bg-teal-950 px-3 py-1`}>
<SansSerifText style={tw`text-xs text-teal-700 dark:text-teal-200`}>
90 min focus
</SansSerifText>
</View>
<View style={tw`rounded-full bg-stone-100 dark:bg-stone-800 px-3 py-1`}>
<SansSerifText style={tw`text-xs text-stone-600 dark:text-stone-300`}>
2 reminders
</SansSerifText>
</View>
<View style={tw`rounded-full bg-stone-100 dark:bg-stone-800 px-3 py-1`}>
<SansSerifText style={tw`text-xs text-stone-600 dark:text-stone-300`}>
Low email volume
</SansSerifText>
</View>
</View>
</FeedCard>
<FeedCard style={tw`bg-white dark:bg-stone-900 p-4 gap-4`}>
<View style={tw`flex-row items-baseline justify-between gap-3`}>
<SerifText style={tw`text-2xl`}>Next up</SerifText>
<SansSerifText style={tw`text-xs text-stone-500 dark:text-stone-400`}>
Today
</SansSerifText>
</View>
<View style={tw`gap-3`}>
<View style={tw`flex-row gap-3`}>
<MonospaceText style={tw`w-14 text-xs text-stone-500 dark:text-stone-400`}>
09:30
</MonospaceText>
<View style={tw`flex-1 gap-1`}>
<SansSerifText style={tw`font-semibold`}>Project sync prep</SansSerifText>
<SansSerifText style={tw`text-sm leading-5 text-stone-600 dark:text-stone-300`}>
Three notes from yesterday are ready to skim.
</SansSerifText>
</View>
</View>
<View style={tw`h-px bg-stone-200 dark:bg-stone-800`} />
<View style={tw`flex-row gap-3`}>
<MonospaceText style={tw`w-14 text-xs text-stone-500 dark:text-stone-400`}>
11:00
</MonospaceText>
<View style={tw`flex-1 gap-1`}>
<SansSerifText style={tw`font-semibold`}>Quiet work block</SansSerifText>
<SansSerifText style={tw`text-sm leading-5 text-stone-600 dark:text-stone-300`}>
Best slot for the proposal before the day gets busy.
</SansSerifText>
</View>
</View>
</View>
</FeedCard>
<FeedCard style={tw`bg-white dark:bg-stone-900 p-4 gap-3`}>
<View style={tw`flex-row items-center justify-between gap-3`}>
<SerifText style={tw`text-2xl`}>Personal radar</SerifText>
<MonospaceText style={tw`text-xs text-stone-500 dark:text-stone-400`}>
4 signals
</MonospaceText>
</View>
<View style={tw`gap-2.5`}>
<View style={tw`flex-row gap-2.5`}>
<View style={tw`mt-1.5 size-2 rounded-full bg-teal-500`} />
<SansSerifText
style={tw`flex-1 text-sm leading-5 text-stone-600 dark:text-stone-300`}
>
Package is likely to arrive before 2 PM.
</SansSerifText>
</View>
<View style={tw`flex-row gap-2.5`}>
<View style={tw`mt-1.5 size-2 rounded-full bg-amber-500`} />
<SansSerifText
style={tw`flex-1 text-sm leading-5 text-stone-600 dark:text-stone-300`}
>
Energy prices dip again after midnight.
</SansSerifText>
</View>
<View style={tw`flex-row gap-2.5`}>
<View style={tw`mt-1.5 size-2 rounded-full bg-sky-500`} />
<SansSerifText
style={tw`flex-1 text-sm leading-5 text-stone-600 dark:text-stone-300`}
>
A recipe you saved matches what is in the fridge.
</SansSerifText>
</View>
</View>
</FeedCard>
<Link href="/components" asChild>
<Pressable style={tw`self-start px-1 py-1`}>
<SansSerifText style={tw`text-sm text-teal-700 dark:text-teal-300`}>
View component library
</SansSerifText>
</Pressable>
</Link>
</ScrollView>
<ChatOverlay />
</View>
)
}

View File

@@ -3,12 +3,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import React from "react"
import { useColorScheme, View } from "react-native"
import { KeyboardProvider } from "react-native-keyboard-controller"
import { useColorScheme } from "react-native"
import tw, { useDeviceContext } from "twrnc"
import { authMiddleware } from "@/api/auth-middleware"
import { ApiClient, ApiClientContext } from "@/api/client"
import { auth, authMiddleware } from "@/auth/auth"
const queryClient = new QueryClient()
const apiClient = new ApiClient({
@@ -23,12 +22,6 @@ export default function RootLayout() {
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
const { data: session, isPending: isLoadingSession } = auth.useSession()
if (isLoadingSession) {
return null
}
return (
<ContextProvider>
<Stack
@@ -37,13 +30,6 @@ export default function RootLayout() {
contentStyle: { backgroundColor: headerBg },
}}
>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
<Stack.Protected guard={Boolean(session)}>
<Stack.Screen name="(app)" />
<Stack.Screen
name="components/index"
options={{
@@ -64,7 +50,6 @@ export default function RootLayout() {
headerShadowVisible: false,
}}
/>
</Stack.Protected>
</Stack>
<StatusBar style="auto" />
</ContextProvider>
@@ -73,10 +58,8 @@ export default function RootLayout() {
function ContextProvider({ children }: React.PropsWithChildren) {
return (
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
</QueryClientProvider>
</KeyboardProvider>
)
}

View File

@@ -0,0 +1,28 @@
import { Link } from "expo-router"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { FeedCard } from "@/components/ui/feed-card"
import { MonospaceText } from "@/components/ui/monospace-text"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
export default function HomeScreen() {
return (
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
<FeedCard>
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
<Button style={tw`self-start`} label="Test" />
</FeedCard>
<Link href="/components" asChild>
<Pressable>
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
</Pressable>
</Link>
</SafeAreaView>
)
}

View File

@@ -1,221 +0,0 @@
import { mutationOptions, useMutation } from "@tanstack/react-query"
import { useRouter } from "expo-router"
/* eslint-disable react-hooks/immutability */
import { useCallback, useImperativeHandle, useRef } from "react"
import { ActivityIndicator, Alert, View } from "react-native"
import { KeyboardAvoidingView, useKeyboardHandler } from "react-native-keyboard-controller"
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
} from "react-native-reanimated"
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"
import tw from "twrnc"
import { auth, signInMutation } from "@/auth/auth"
import { InvalidCredentialsError, BetterAuthError } from "@/auth/error"
import { Button } from "@/components/ui/button"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
import { TextInput } from "@/components/ui/text-input"
export default function SignInPage() {
console.log("sing in page ")
const loginFormRef = useRef<LoginFormContainerRef>(null)
const emailButtonHeight = useRef(0)
return (
<View style={tw`size-full relative`}>
<SafeAreaView
style={tw`flex-1 bg-stone-50 dark:bg-stone-900 justify-center items-start px-6`}
>
<View style={tw`flex-1 justify-center items-start`}>
<SerifText style={tw`text-lg mb-1.5`}>I&apos;m Freya!</SerifText>
<SerifText style={tw`text-lg opacity-70 leading-tight`}>
Before I can help you with your daily routines, please sign in below.
</SerifText>
</View>
<Button
onLayout={({ nativeEvent: { layout } }) => {
emailButtonHeight.current = layout.height
}}
intent="secondary"
style={tw`w-full`}
onPress={() => {
loginFormRef.current?.show({
fromHeight: emailButtonHeight.current,
})
}}
>
<Button.Label>Continue with email</Button.Label>
</Button>
</SafeAreaView>
<LoginFormContainer ref={loginFormRef}>
<LoginForm />
</LoginFormContainer>
</View>
)
}
interface LoginFormContainerRef {
show: ({ fromHeight }: { fromHeight: number }) => void
}
function LoginFormContainer({
ref,
children,
}: React.PropsWithChildren<{ ref?: React.Ref<LoginFormContainerRef> }>) {
console.log("LoginFormContainer")
const safeAreaInsets = useSafeAreaInsets()
const opacity = useSharedValue(0)
const contentOpacity = useSharedValue(0)
const insetX = useSharedValue(0)
const bottom = useSharedValue(0)
const height = useSharedValue(0)
const finalHeight = useRef(0)
const show = useCallback(
({ fromHeight }: { fromHeight: number }) => {
insetX.value = 24
bottom.value = safeAreaInsets.bottom
height.value = fromHeight
opacity.value = 1
insetX.value = withSpring(0)
bottom.value = withSpring(0)
height.value = withSpring(finalHeight.current)
contentOpacity.value = withDelay(100, withSpring(1))
},
[opacity, insetX, bottom, safeAreaInsets.bottom, height, contentOpacity],
)
useImperativeHandle(ref, () => ({ show }))
useKeyboardHandler({
onMove: ({ progress, height, duration }) => {
"worklet"
bottom.value = height
},
})
const animatedStyle = useAnimatedStyle(() => ({
height: opacity.value !== 0 ? height.value : undefined,
opacity: opacity.value,
left: insetX.value,
right: insetX.value,
bottom: bottom.value,
}))
return (
<Animated.View
onLayout={({ nativeEvent: { layout } }) => {
finalHeight.current = layout.height
}}
style={[
tw`absolute overflow-hidden border border-stone-200 dark:border-stone-700 rounded-2xl`,
animatedStyle,
]}
>
<KeyboardAvoidingView behavior="padding">
<SafeAreaView
edges={["bottom"]}
style={tw`px-4 bg-stone-100 dark:bg-stone-800 overflow-hidden`}
>
<Animated.View style={[tw`w-full`, { opacity: contentOpacity }]}>
{children}
</Animated.View>
</SafeAreaView>
</KeyboardAvoidingView>
</Animated.View>
)
}
function LoginForm() {
console.log("LoginForm")
const emailRef = useRef("")
const passwordRef = useRef("")
const router = useRouter()
const { mutate: signIn, isPending: isSigningIn } = useMutation(
mutationOptions({
...signInMutation,
onSuccess: (data) => {
if (data) {
router.replace("/")
} else {
// if no data is returned, nothing was done, so do nothing
}
},
onError: (error) => {
console.log(error)
if (error instanceof InvalidCredentialsError) {
Alert.alert("Failed to sign in", "Incorrect email or password")
} else if (error instanceof BetterAuthError) {
Alert.alert(
"Failed to sign in",
"This is a fault on Freya's end. Please try again later.",
)
} else {
Alert.alert(
"Unable to connect to Freya",
"Please check your internet connection and try again.",
)
}
},
}),
)
const handleSignInButtonPress = () => {
signIn({
email: emailRef.current,
password: passwordRef.current,
})
}
return (
<View style={[tw`w-full py-4`]}>
<View style={tw`flex flex-row w-full`}>
<View>
<View style={tw`h-8 justify-center mr-4`}>
<SansSerifText>Email</SansSerifText>
</View>
<View style={tw`my-1 h-px w-full bg-stone-200 dark:bg-stone-700 rounded-l-full`} />
<View style={tw`h-8 justify-center mr-4`}>
<SansSerifText>Password</SansSerifText>
</View>
</View>
<View style={tw`flex-1`}>
<TextInput
defaultValue=""
autoCapitalize="none"
keyboardType="email-address"
style={tw`w-full h-8 font-medium`}
onChangeText={(text) => {
emailRef.current = text
}}
/>
<View style={tw`my-1 h-px w-full bg-stone-200 dark:bg-stone-700 rounded-r-full`} />
<TextInput
defaultValue=""
secureTextEntry
style={tw`w-full h-8 font-medium`}
onChangeText={(text) => {
passwordRef.current = text
}}
/>
</View>
</View>
<Button
intent="primary"
style={tw`w-full mt-6`}
onPress={handleSignInButtonPress}
enabled={!isSigningIn}
>
{isSigningIn ? <Button.Loading /> : <Button.Label>Sign in</Button.Label>}
</Button>
</View>
)
}

View File

@@ -1,57 +0,0 @@
import { expoClient } from "@better-auth/expo/client"
import { mutationOptions } from "@tanstack/react-query"
import { createAuthClient } from "better-auth/react"
import * as SecureStore from "expo-secure-store"
import type { ApiRequestMiddleware } from "../api/client"
import { BetterAuthError, InvalidCredentialsError } from "./error"
export const auth = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_SERVER_URL,
plugins: [
expoClient({
scheme: "freya",
storagePrefix: "chat.freya",
storage: SecureStore,
}),
],
})
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
const cookie = auth.getCookie()
const headers = new Headers(init.headers)
if (cookie) {
headers.set("Cookie", cookie)
}
return {
...init,
credentials: "omit",
headers,
}
}
export const signInMutation = mutationOptions({
mutationFn: async ({ email, password }: { email: string; password: string }) => {
if (email && password) {
const result = await auth.signIn.email({
email,
password,
})
if (result.error?.code) {
switch (result.error.code) {
case "INVALID_EMAIL":
throw new InvalidCredentialsError("Invalid email")
case "INVALID_PASSWORD":
throw new InvalidCredentialsError("Invalid password")
case "INVALID_EMAIL_OR_PASSWORD":
throw new InvalidCredentialsError("Invalid email or password")
default:
throw new BetterAuthError(result.error)
}
}
return result
}
return null
},
})

View File

@@ -1,27 +0,0 @@
import type { auth } from "./auth"
export class InvalidCredentialsError extends Error {
constructor(cause: unknown) {
super(`Invalid credentials: ${cause}`)
}
}
export class BetterAuthError extends Error {
// the type is copied from the shape of result.error from authClient.signIn.email
constructor(error: {
code?: string | undefined
message?: string | undefined
status: number
statusText: string
}) {
super(`${error.message ?? "BetterAuthError"}: ${error.status} ${error.statusText}`)
}
}
type BetterAuthErrorTypes = Partial<Record<keyof typeof auth.$ERROR_CODES, string>>
export const AuthErrorCode = {
INVALID_EMAIL: "INVALID_EMAIL",
INVALID_PASSWORD: "INVALID_PASSWORD",
INVALID_EMAIL_OR_PASSWORD: "INVALID_EMAIL_OR_PASSWORD",
} satisfies BetterAuthErrorTypes

View File

@@ -8,28 +8,25 @@ function ButtonShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<Button style={tw`self-start`}>
<Button.Label>Press me</Button.Label>
</Button>
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button style={tw`self-start`}>
<Button.Icon name="plus" />
<Button.Label>Add item</Button.Label>
</Button>
<Button style={tw`self-start`} label="Add item" leadingIcon={<Button.Icon name="plus" />} />
</Section>
<Section title="Trailing icon">
<Button style={tw`self-start`}>
<Button.Label>Next</Button.Label>
<Button.Icon name="arrow-right" />
</Button>
<Button
style={tw`self-start`}
label="Next"
trailingIcon={<Button.Icon name="arrow-right" />}
/>
</Section>
<Section title="Both icons">
<Button style={tw`self-start`}>
<Button.Icon name="download" />
<Button.Label>Download</Button.Label>
<Button.Icon name="chevron-down" />
</Button>
<Button
style={tw`self-start`}
label="Download"
leadingIcon={<Button.Icon name="download" />}
trailingIcon={<Button.Icon name="chevron-down" />}
/>
</Section>
</View>
)

View File

@@ -1,214 +1,47 @@
import Feather from "@expo/vector-icons/Feather"
import { createContext, useContext } from "react"
import {
type PressableProps,
Pressable,
type TextStyle,
useColorScheme,
ActivityIndicator,
} from "react-native"
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { rva, type RvaProps } from "@/lib/rva"
import { SansSerifText } from "./sans-serif-text"
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
const button = rva(
tw.style("rounded-2xl px-4 py-3 w-fit flex-row items-center justify-center gap-1.5 h-10", {
borderCurve: "continuous",
}),
{
variants: {
intent: {
primary: tw`bg-teal-600`,
secondary: tw`bg-stone-100 dark:bg-stone-800`,
},
pressed: {
true: tw`translate-y-px`,
false: null,
},
enabled: {
true: null,
false: tw`opacity-50`,
},
dark: {
true: null,
false: null,
},
},
defaultVariants: {
intent: "primary",
pressed: false,
enabled: true,
dark: false,
},
compoundVariants: [
// primary variants
{
intent: "primary",
enabled: true,
pressed: false,
style: {
boxShadow:
"inset 0 1px 0 0 #2dd4bf66, inset 0 -1px 0 0 #0f766eb3, 0 2px 4px 0 #0000001a, 0 0 0 1px #0f766e",
},
},
{
intent: "primary",
enabled: true,
pressed: true,
style: tw.style("bg-teal-700", {
boxShadow:
"inset 0 1px 2px 0 #042f2e80, inset 0 0 0 1px #0f766e, inset 0 -1px 0 0 #2dd4bf26",
}),
},
// secondary variants
{
intent: "secondary",
dark: false,
enabled: true,
pressed: false,
style: {
boxShadow: "inset 0 1px 0 0 #fdfdfd, 0 2px 4px 0 #0000001a, 0 0 0 1px #00000029",
},
},
{
intent: "secondary",
dark: false,
enabled: true,
pressed: true,
style: tw.style("bg-stone-200", {
boxShadow:
"inset 0 1px 2px 0 #0000001f, inset 0 0 0 1px #00000012, inset 0 -1px 0 0 #ffffff80",
}),
},
{
intent: "secondary",
dark: true,
enabled: true,
pressed: false,
style: tw.style("bg-stone-800", {
boxShadow:
"inset 0 1px 0 0 #4b4951, inset 0 -1px 0 0 #313036, 0 2px 4px 0 #0000001a, 0 0 0 1px #0d0d0d",
}),
},
{
intent: "secondary",
dark: true,
enabled: true,
pressed: true,
style: tw.style("bg-stone-900", {
boxShadow:
"inset 0 1px 2px 0 #00000080, inset 0 0 0 1px #00000066, inset 0 -1px 0 0 #ffffff0a",
}),
},
],
},
)
const label = rva<TextStyle>(
{},
{
variants: {
intent: {
primary: tw`text-stone-100 dark:text-stone-200 font-medium`,
secondary: tw`text-stone-800 dark:text-stone-200 font-medium`,
},
},
},
)
type ButtonVariants = Omit<RvaProps<typeof button>, "dark" | "pressed">
type ButtonProps = PressableProps & ButtonVariants
interface ButtonContext extends ButtonVariants {}
const Context = createContext<ButtonContext>({})
export function Button({ style, intent = "primary", enabled = true, ...props }: ButtonProps) {
const theme = useColorScheme()
return (
<Context value={{ enabled, intent }}>
<Pressable
style={(state) => [
button({
intent,
enabled,
pressed: state.pressed,
dark: theme === "dark",
}),
typeof style === "function" ? style(state) : style,
]}
{...props}
/>
</Context>
)
}
type ButtonIconProps = {
name: FeatherIconName
}
function ButtonIcon({ name }: ButtonIconProps) {
const context = useContext(Context)
let color: string
switch (context.intent) {
case "primary":
color = tw.color("text-stone-100 dark:text-stone-200") ?? ""
break
case "secondary":
color = tw.color("text-stone-800 dark:text-stone-200") ?? ""
break
default:
color = ""
break
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
}
return <Feather name={name} size={18} color={color} />
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
type ButtonLabelProps = React.ComponentProps<typeof SansSerifText>
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = (
<SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>
{label}
</SansSerifText>
)
function ButtonLabel({ style, ...props }: ButtonLabelProps) {
const context = useContext(Context)
return (
<SansSerifText
style={[
label({
intent: context.intent,
}),
style,
]}
{...props}
/>
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
{hasIcons ? (
<View style={tw`flex-row items-center gap-1.5`}>
{leadingIcon}
{textElement}
{trailingIcon}
</View>
) : (
textElement
)}
</Pressable>
)
}
function ButtonLoadingIndicator() {
const context = useContext(Context)
let color: string
switch (context.intent) {
case "primary":
color = tw.color("text-stone-100 dark:text-stone-200") ?? ""
break
case "secondary":
color = tw.color("text-stone-800 dark:text-stone-200") ?? ""
break
default:
color = ""
break
}
return <ActivityIndicator color={color} />
}
Button.Icon = ButtonIcon
Button.Label = ButtonLabel
Button.Loading = ButtonLoadingIndicator

View File

@@ -19,9 +19,7 @@ function FeedCardShowcase() {
<FeedCard style={tw`p-4 gap-2`}>
<SerifText style={tw`text-xl`}>Title</SerifText>
<SansSerifText>Body text inside a feed card.</SansSerifText>
<Button style={tw`self-start mt-2`}>
<Button.Label>Action</Button.Label>
</Button>
<Button style={tw`self-start mt-2`} label="Action" />
</FeedCard>
</Section>
</View>

View File

@@ -1,6 +0,0 @@
import { TextInput as NativeTextInput, type TextInputProps } from "react-native"
import tw from "twrnc"
export function TextInput({ style, ...props }: TextInputProps) {
return <NativeTextInput style={[tw`text-stone-800 dark:text-stone-200`, style]} {...props} />
}

View File

@@ -1,284 +0,0 @@
/* eslint-disable react-hooks/immutability */
import MaskedView from "@react-native-masked-view/masked-view"
import { BlurView } from "expo-blur"
import { LinearGradient } from "expo-linear-gradient"
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"
import { useColorScheme, View, StyleSheet, Platform, Dimensions } from "react-native"
import { easeGradient } from "react-native-easing-gradient"
import { useKeyboardHandler } from "react-native-keyboard-controller"
import Animated, { FadeIn, FadeOut, useSharedValue, withSpring } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { TextInput } from "@/components/ui/text-input"
import { ConversationList } from "./conversation-list"
interface BottomProgressiveBlurRef {
setBlurHeight: (height: number) => void
}
interface ConversationListContainerRef {
showFullChat: () => void
}
const ChatViewMode = {
Hidden: "hidden",
Peek: "peek",
FullChat: "full-chat",
} as const
type ChatViewMode = (typeof ChatViewMode)[keyof typeof ChatViewMode]
const chatInputHeightAtom = atom(0)
const isChatInputFocusedAtom = atom(false)
const chatViewModeAtom = atom<ChatViewMode>(ChatViewMode.Hidden)
export function ChatOverlay() {
const theme = useColorScheme()
const setChatInputHeight = useSetAtom(chatInputHeightAtom)
const setIsChatInputFocused = useSetAtom(isChatInputFocusedAtom)
const setChatViewMode = useSetAtom(chatViewModeAtom)
const store = useStore()
const conversationListContainerRef = useRef<ConversationListContainerRef>(null)
const onTextInputFocus = () => {
setChatViewMode(ChatViewMode.Peek)
}
const onConversationListScroll = () => {
if (store.get(chatViewModeAtom) !== ChatViewMode.FullChat) {
setChatViewMode(ChatViewMode.FullChat)
conversationListContainerRef?.current?.showFullChat()
}
}
return (
<ChatOverlayContainer>
<OverlayBackdrop />
<ConversationListContainer ref={conversationListContainerRef}>
<ConversationList
ListHeaderComponent={ConversationListHeader}
ListFooterComponent={ConversationListFooter}
onScrollBeginDrag={onConversationListScroll}
/>
</ConversationListContainer>
<ChatInputContainer>
<BlurView
onLayout={({ nativeEvent: { layout } }) => {
setChatInputHeight(layout.height)
}}
intensity={35}
tint={theme === "dark" ? "systemThickMaterialDark" : "systemThickMaterialLight"}
style={tw`flex flex-row w-full py-1 pl-4 pr-1 border border-stone-300 dark:border-stone-700 rounded-full overflow-hidden`}
>
<TextInput
onFocus={onTextInputFocus}
onBlur={() => {
setIsChatInputFocused(false)
}}
style={tw`flex-1`}
placeholder="Message Freya..."
/>
<Button style={tw`size-8 p-0`}>
<Button.Icon name="arrow-up" />
</Button>
</BlurView>
</ChatInputContainer>
</ChatOverlayContainer>
)
}
function ChatOverlayContainer({ children }: React.PropsWithChildren) {
const bottom = useSharedValue(0)
useKeyboardHandler({
onMove: (event) => {
"worklet"
bottom.value = event.height
},
})
return (
<Animated.View pointerEvents="box-none" style={[tw`absolute top-0 left-0 right-0`, { bottom }]}>
{children}
</Animated.View>
)
}
function ConversationListContainer({
ref,
children,
}: React.PropsWithChildren<{ ref?: React.Ref<ConversationListContainerRef> }>) {
const chatViewMode = useAtomValue(chatViewModeAtom)
const height = useSharedValue(Dimensions.get("window").height * 0.4)
const { colors, locations } = useMemo(
() =>
easeGradient({
colorStops: {
0: { color: "transparent" },
0.1: { color: "transparent" },
0.3: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
0.9: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
},
}),
[],
)
const showFullChat = useCallback(() => {
height.value = withSpring(Dimensions.get("window").height + 80)
}, [height])
useImperativeHandle(
ref,
() => ({
showFullChat,
}),
[showFullChat],
)
return (
<View pointerEvents="box-none" style={tw.style("absolute top-0 left-0 right-0 bottom-0")}>
<MaskedView
pointerEvents="box-none"
maskElement={
<Animated.View style={[tw`absolute bottom-0 right-0 left-0`, { height }]}>
<LinearGradient
locations={locations as any}
colors={colors as any}
style={tw`size-full`}
/>
</Animated.View>
}
style={tw`size-full`}
>
<View
style={tw.style("size-full", chatViewMode === ChatViewMode.Hidden ? "opacity-0" : "")}
>
{children}
</View>
</MaskedView>
</View>
)
}
function OverlayBackdrop() {
const chatViewMode = useAtomValue(chatViewModeAtom)
const bottomProgressiveBlurRef = useRef<BottomProgressiveBlurRef>(null)
useEffect(() => {
if (chatViewMode === ChatViewMode.Peek) {
bottomProgressiveBlurRef?.current?.setBlurHeight(Dimensions.get("window").height * 0.75)
}
}, [chatViewMode])
if (chatViewMode === ChatViewMode.FullChat) {
return <BlurBackground />
}
return <BottomProgressiveBlur ref={bottomProgressiveBlurRef} />
}
function BottomProgressiveBlur({ ref }: { ref?: React.Ref<BottomProgressiveBlurRef> }) {
const progressiveBlurHeight = useSharedValue(192)
const colorScheme = useColorScheme()
const { colors, locations } = useMemo(
() =>
easeGradient({
colorStops: {
0: { color: "transparent" },
0.7: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
},
}),
[],
)
const setBlurHeight = useCallback(
(height: number) => {
progressiveBlurHeight.value = withSpring(height)
},
[progressiveBlurHeight],
)
useImperativeHandle(ref, () => ({ setBlurHeight }), [setBlurHeight])
return (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={[tw`absolute bottom-0 left-0 right-0`, { height: progressiveBlurHeight }]}
>
<MaskedView
maskElement={
<LinearGradient
locations={locations as any}
colors={colors as any}
style={tw`absolute top-0 bottom-0 left-0 right-0`}
/>
}
style={[StyleSheet.absoluteFill, tw`z-[1]`]}
>
<BlurView
intensity={65}
tint={Platform.select({
ios:
colorScheme === "dark"
? "systemUltraThinMaterialDark"
: "systemUltraThinMaterialLight",
android: "systemMaterialDark",
})}
style={StyleSheet.absoluteFill}
/>
</MaskedView>
</Animated.View>
)
}
function BlurBackground() {
const colorScheme = useColorScheme()
return (
<Animated.View entering={FadeIn} exiting={FadeOut} style={StyleSheet.absoluteFill}>
<BlurView
intensity={65}
tint={Platform.select({
ios:
colorScheme === "dark" ? "systemUltraThinMaterialDark" : "systemUltraThinMaterialLight",
android: "systemMaterialDark",
})}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
)
}
function ChatInputContainer({ children }: React.PropsWithChildren) {
const keyboardHeight = useSharedValue(0)
const insets = useSafeAreaInsets()
useKeyboardHandler({
onMove: (event) => {
"worklet"
keyboardHeight.value = Math.max(event.height - insets.bottom + 8, 0)
},
})
return <View style={tw`absolute bottom-0 left-0 right-0 px-4 pb-10`}>{children}</View>
}
function ConversationListHeader() {
const safeAreaInsets = useSafeAreaInsets()
return <View style={{ height: safeAreaInsets.top }} />
}
function ConversationListFooter() {
const chatInputHeight = useAtomValue(chatInputHeightAtom)
const safeAreaInsets = useSafeAreaInsets()
return <View style={{ height: chatInputHeight + 24 + safeAreaInsets.bottom }} />
}

View File

@@ -1,183 +0,0 @@
import { AssistantMessagePayload, UserMessagePayload } from "@freya/core"
import { FlashList } from "@shopify/flash-list"
import { useQuery } from "@tanstack/react-query"
import { View, ViewStyle } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { ConversationEntry } from "./conversations"
import { useListConversationEntriesQuery, useDefaultConversationQuery } from "./queries"
type ConversationListProps = Omit<
React.ComponentProps<typeof FlashList>,
"data" | "keyExtractor" | "renderItem" | "maintainVisibleContentPosition"
>
type PositionInGroup = "single" | "first" | "in-between" | "last"
const messageBubbleRadius = 18
const groupedMessageBubbleRadius = 6
export function ConversationList({
ListFooterComponent,
ListHeaderComponent,
onScrollBeginDrag,
}: ConversationListProps) {
const { data: conversation } = useQuery(useDefaultConversationQuery())
const { data: entries } = useQuery(useListConversationEntriesQuery(conversation?.id))
const conversationEntries = entries ?? []
return (
<FlashList
style={tw`size-full`}
maintainVisibleContentPosition={{ startRenderingFromBottom: true }}
data={conversationEntries}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => {
const previousEntryIsSameKind = conversationEntries[index - 1]?.kind === item.kind
const nextEntryIsSameKind = conversationEntries[index + 1]?.kind === item.kind
let position: PositionInGroup
if (!previousEntryIsSameKind && !nextEntryIsSameKind) {
position = "single"
} else if (!previousEntryIsSameKind) {
position = "first"
} else if (!nextEntryIsSameKind) {
position = "last"
} else {
position = "in-between"
}
return <MessageBubble entry={item} position={position} />
}}
onScrollBeginDrag={onScrollBeginDrag}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
/>
)
}
function MessageBubble({
entry,
position,
}: {
entry: typeof ConversationEntry.infer
position: PositionInGroup
}) {
if (entry.kind === "user_message") {
const payload = UserMessagePayload.assert(entry.payload)
return <UserMessageBubble payload={payload} position={position} />
}
if (entry.kind === "assistant_message") {
const payload = AssistantMessagePayload.assert(entry.payload)
return <AssistantMessageBubble payload={payload} position={position} />
}
return null
}
function UserMessageBubble({
payload,
position,
}: {
payload: UserMessagePayload
position: PositionInGroup
}) {
const content = payload.parts.reduce((final, part) => {
if (part.type === "text") {
return final + part.text
}
return final
}, "")
let corners: ViewStyle
switch (position) {
case "single":
case "first":
corners = {
borderRadius: messageBubbleRadius,
borderBottomRightRadius: groupedMessageBubbleRadius,
}
break
case "in-between":
corners = {
borderRadius: messageBubbleRadius,
borderTopRightRadius: groupedMessageBubbleRadius,
borderBottomRightRadius: groupedMessageBubbleRadius,
}
break
case "last":
corners = {
borderRadius: messageBubbleRadius,
borderTopRightRadius: groupedMessageBubbleRadius,
}
break
}
return (
<View style={tw`w-full flex-row justify-end mb-4 pr-4`}>
<View
style={tw.style("bg-teal-600 px-3 py-2 overflow-hidden max-w-56", corners, {
borderCurve: "circular",
})}
>
<SansSerifText style={tw`text-stone-100`}>{content}</SansSerifText>
</View>
</View>
)
}
function AssistantMessageBubble({
payload,
position,
}: {
payload: AssistantMessagePayload
position: PositionInGroup
}) {
const content = payload.parts.reduce((final, part) => {
if (part.type === "text") {
return final + part.text
}
return final
}, "")
let corners: ViewStyle
switch (position) {
case "single":
case "first":
corners = {
borderRadius: messageBubbleRadius,
borderBottomLeftRadius: groupedMessageBubbleRadius,
}
break
case "in-between":
corners = {
borderRadius: messageBubbleRadius,
borderTopLeftRadius: groupedMessageBubbleRadius,
borderBottomLeftRadius: groupedMessageBubbleRadius,
}
break
case "last":
corners = {
borderRadius: messageBubbleRadius,
borderTopLeftRadius: groupedMessageBubbleRadius,
}
break
}
return (
<View style={tw`w-full flex-row justify-start mb-4 pl-4`}>
<View
style={tw.style(
"bg-stone-200 dark:bg-stone-800 border border-stone-300 dark:border-stone-700 px-3 py-2 overflow-hidden max-w-56",
corners,
{
borderCurve: "circular",
},
)}
>
<SansSerifText style={tw`text-stone-950 dark:text-stone-100`}>{content}</SansSerifText>
</View>
</View>
)
}

View File

@@ -1,21 +0,0 @@
import {
ConversationEntryKind,
ConversationEntryPayload,
ConversationEntryVisibility,
} from "@freya/core"
import { type } from "arktype"
export const Conversation = type({
id: "string.uuid",
createdAt: "string.date.iso",
updatedAt: "string.date.iso",
})
export const ConversationEntry = type({
id: "string.uuid",
sequence: "number",
kind: type.enumerated(...Object.values(ConversationEntryKind)),
visibility: type.enumerated(...Object.values(ConversationEntryVisibility)),
fileId: "string | null",
payload: ConversationEntryPayload,
})

View File

@@ -1,47 +0,0 @@
import { queryOptions, skipToken } from "@tanstack/react-query"
import { type } from "arktype"
import { useApiClient } from "@/api/client"
import { Conversation, ConversationEntry } from "./conversations"
const ListConversationsResponse = type({
conversations: Conversation.array(),
})
const ConversationEntriesResponse = type({
entries: ConversationEntry.array(),
})
export function useListConversationsQuery() {
const api = useApiClient()
return queryOptions({
queryKey: ["conversations"],
queryFn: async () =>
api
.request("/conversations", { method: "GET" })
.then(([, json]) => ListConversationsResponse.assert(json)),
})
}
export function useDefaultConversationQuery() {
return queryOptions({
...useListConversationsQuery(),
select: (data) => {
return data.conversations.length === 0 ? null : data.conversations[0]
},
})
}
export function useListConversationEntriesQuery(id?: string) {
const api = useApiClient()
return queryOptions({
queryKey: ["conversations", id],
queryFn: id
? async () =>
api
.request(`/conversations/${id}/entries`, { method: "GET" })
.then(([, json]) => ConversationEntriesResponse.assert(json).entries)
: skipToken,
})
}

View File

@@ -15,29 +15,15 @@ export const catalog = defineCatalog(schema, {
},
Button: {
props: z.object({
intent: z.enum(["primary", "secondary"]).nullable(),
label: z.string(),
leadingIcon: z.string().nullable(),
trailingIcon: z.string().nullable(),
}),
events: ["press"],
slots: ["default"],
slots: [],
description:
"Pressable button. Add ButtonLabel and optional ButtonIcon children in the default slot. Bind on.press to trigger an action.",
example: { intent: "primary" },
},
ButtonIcon: {
props: z.object({
name: z.string(),
}),
slots: [],
description: "Feather icon for use inside a Button.",
example: { name: "plus" },
},
ButtonLabel: {
props: z.object({
text: z.string(),
}),
slots: [],
description: "Text label for use inside a Button.",
example: { text: "Add item" },
"Pressable button with a label and optional Feather icons. Icon values are Feather icon names (e.g. 'plus', 'arrow-right'). Bind on.press to trigger an action.",
example: { label: "Add item", leadingIcon: "plus", trailingIcon: null },
},
FeedCard: {
props: z.object({

View File

@@ -17,13 +17,20 @@ export const { registry } = defineRegistry(catalog, {
View: ({ props, children }) => (
<View style={props.style ? tw`${props.style}` : undefined}>{children}</View>
),
Button: ({ props, children, emit }) => (
<Button intent={props.intent ?? undefined} onPress={() => emit("press")}>
{children}
</Button>
Button: ({ props, emit }) => (
<Button
label={props.label}
leadingIcon={
props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined
}
trailingIcon={
props.trailingIcon ? (
<Button.Icon name={props.trailingIcon as ButtonIconName} />
) : undefined
}
onPress={() => emit("press")}
/>
),
ButtonIcon: ({ props }) => <Button.Icon name={props.name as ButtonIconName} />,
ButtonLabel: ({ props }) => <Button.Label>{props.text}</Button.Label>,
FeedCard: ({ props, children }) => (
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
),

View File

@@ -1,215 +0,0 @@
import type { StyleProp, ViewStyle } from "react-native"
type RvaNoInfer<TValue> = [TValue][TValue extends unknown ? 0 : never]
type VariantValue = string | number | boolean | null | undefined
type VariantOptionKey<TOptions> = Extract<keyof TOptions, string>
type BooleanVariantInput<TOptions> =
Extract<VariantOptionKey<TOptions>, "true" | "false"> extends never ? never : boolean
type StringVariantInput<TOptions> = Exclude<VariantOptionKey<TOptions>, "true" | "false">
type VariantInput<TOptions> =
| StringVariantInput<TOptions>
| BooleanVariantInput<TOptions>
| null
| undefined
type CompoundCondition<TVariants extends RvaVariants<unknown>> = {
name: keyof TVariants
value: string
}
type NormalizedCompoundVariant<TStyle, TVariants extends RvaVariants<TStyle>> = {
conditions: CompoundCondition<TVariants>[]
style: StyleProp<TStyle>
}
export type RvaVariants<TStyle = unknown> = {
readonly [name: string]: {
readonly [value: string]: StyleProp<TStyle>
}
}
export type RvaVariantProps<TVariants extends RvaVariants<unknown>> = {
readonly [TName in keyof TVariants]?: VariantInput<TVariants[TName]>
}
type MutableRvaVariantProps<TVariants extends RvaVariants<unknown>> = {
-readonly [TName in keyof TVariants]?: VariantInput<TVariants[TName]>
}
export type RvaCompoundVariant<
TStyle,
TVariants extends RvaVariants<TStyle>,
> = RvaVariantProps<TVariants> & {
readonly style: StyleProp<TStyle>
}
export type RvaConfig<TStyle, TVariants extends RvaVariants<TStyle>> = {
readonly variants?: TVariants
readonly defaultVariants?: RvaVariantProps<TVariants>
readonly compoundVariants?: readonly RvaCompoundVariant<TStyle, TVariants>[]
}
export type RvaResolver<TStyle, TVariants extends RvaVariants<TStyle>> = (
props?: RvaVariantProps<TVariants>,
) => StyleProp<TStyle>
export type RvaProps<TResolver> = TResolver extends (props?: infer TProps) => unknown
? NonNullable<TProps>
: never
export function rva<TStyle = ViewStyle>(): <
const TVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>,
>(
base: StyleProp<RvaNoInfer<TStyle>>,
config?: RvaConfig<RvaNoInfer<TStyle>, TVariants>,
) => RvaResolver<TStyle, TVariants>
export function rva<
TStyle = ViewStyle,
const TVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>,
>(
base: StyleProp<RvaNoInfer<TStyle>>,
config?: RvaConfig<RvaNoInfer<TStyle>, TVariants>,
): RvaResolver<TStyle, TVariants>
export function rva<
TStyle = ViewStyle,
const TVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>,
>(
base?: StyleProp<TStyle>,
config?: RvaConfig<TStyle, TVariants>,
):
| RvaResolver<TStyle, TVariants>
| (<const TTypedVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>>(
base: StyleProp<TStyle>,
config?: RvaConfig<TStyle, TTypedVariants>,
) => RvaResolver<TStyle, TTypedVariants>) {
if (base === undefined && config === undefined) {
return function createTypedRva<
const TTypedVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>,
>(typedBase: StyleProp<TStyle>, typedConfig: RvaConfig<TStyle, TTypedVariants> = {}) {
return createRva(typedBase, typedConfig)
}
}
return createRva(base, config ?? {})
}
function createRva<TStyle, const TVariants extends RvaVariants<TStyle> = RvaVariants<TStyle>>(
base: StyleProp<TStyle>,
config: RvaConfig<TStyle, TVariants>,
): RvaResolver<TStyle, TVariants> {
const compoundVariants = normalizeCompoundVariants(config.compoundVariants)
return function resolveRva(props: RvaVariantProps<TVariants> = {}) {
const merged = mergeVariantProps(config.defaultVariants, props)
const styles: StyleProp<TStyle>[] = [base]
const variants = config.variants
if (variants !== undefined) {
for (const name in variants) {
const value = normalizeVariantValue(merged[name])
if (value === undefined) {
continue
}
const style = variants[name]?.[value]
if (style !== undefined) {
styles.push(style)
}
}
}
for (const compound of compoundVariants) {
if (compoundMatches(compound, merged)) {
styles.push(compound.style)
}
}
return styles
}
}
function mergeVariantProps<TVariants extends RvaVariants<unknown>>(
defaultVariants: RvaVariantProps<TVariants> | undefined,
props: RvaVariantProps<TVariants>,
): RvaVariantProps<TVariants> {
const merged: MutableRvaVariantProps<TVariants> = {}
if (defaultVariants !== undefined) {
for (const name of Object.keys(defaultVariants) as (keyof TVariants)[]) {
const value = defaultVariants[name]
if (value !== undefined) {
merged[name] = value
}
}
}
for (const name of Object.keys(props) as (keyof TVariants)[]) {
const value = props[name]
if (value !== undefined) {
merged[name] = value
}
}
return merged
}
function normalizeCompoundVariants<TStyle, TVariants extends RvaVariants<TStyle>>(
compoundVariants: readonly RvaCompoundVariant<TStyle, TVariants>[] | undefined,
) {
const normalized: NormalizedCompoundVariant<TStyle, TVariants>[] = []
if (compoundVariants === undefined) {
return normalized
}
for (const compound of compoundVariants) {
const conditions: CompoundCondition<TVariants>[] = []
for (const name in compound) {
if (name === "style") {
continue
}
const variantName = name as keyof TVariants
const value = normalizeVariantValue(compound[variantName])
if (value !== undefined) {
conditions.push({ name: variantName, value })
}
}
normalized.push({ conditions, style: compound.style })
}
return normalized
}
function compoundMatches<TStyle, TVariants extends RvaVariants<TStyle>>(
compound: NormalizedCompoundVariant<TStyle, TVariants>,
props: RvaVariantProps<TVariants>,
) {
for (const condition of compound.conditions) {
if (normalizeVariantValue(props[condition.name]) !== condition.value) {
return false
}
}
return true
}
function normalizeVariantValue(value: VariantValue) {
if (value === null || value === undefined) {
return undefined
}
if (value === true) {
return "true"
}
if (value === false) {
return "false"
}
return String(value)
}

View File

@@ -16,9 +16,9 @@
"lottie-react": "^2.4.1",
"lucide-react": "^0.577.0",
"motion": "^12.35.0",
"react": "19.2.3",
"react": "^19.2.4",
"react-aria-components": "^1.16.0",
"react-dom": "19.2.3",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"resend": "^6.9.3",
"streamdown": "^2.4.0"

2018
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[install]
linker = "hoisted"

27
flake.lock generated
View File

@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

282
flake.nix
View File

@@ -1,282 +0,0 @@
{
description = "FREYA development shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ nixpkgs, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
lib = nixpkgs.lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = forEachSystem (system: import nixpkgs { inherit system; });
# App outputs are for long-running local tools and dev servers.
appScripts = {
expo = "expo";
drizzle-studio = "drizzle-studio";
freya-backend = "freya-backend";
admin-dashboard = "admin-dashboard";
agent-test-cli = "agent-test-cli";
};
# Check outputs are the CI-like validation commands run by `nix flake check`.
checkCommands = {
format-check = "bun run format:check";
lint = "bun run lint";
test = "bun run test";
};
# Dev-shell conveniences mirror the common app/check commands.
shellScripts = appScripts // {
freya-test = "test";
lint = "lint";
format-check = "format:check";
};
# node_modules is content-addressed. If bun.lock or package manifests
# change, Nix will report the new hash to put here.
nodeModulesHashes = {
x86_64-linux = "sha256-8uhlaQAFfCgGdUlrz8sqhtIkC/WfdasbTCi3p/NkU/w=";
};
checkSystems = lib.attrNames nodeModulesHashes;
# Dependency derivations only need the lockfile and workspace manifests,
# so source-only edits do not force Bun to reinstall.
dependencySource = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.fileFilter (
file: file.name == "bun.lock" || file.name == "package.json" || file.name == "bunfig.toml"
) ./.;
};
# Checks run against a clean source tree, even when using `path:.`.
# Without this filter, local node_modules can sneak into the Nix sandbox.
projectSource = builtins.path {
name = "freya-source";
path = ./.;
filter =
path: type:
let
name = builtins.baseNameOf path;
in
!(type == "directory" && (name == ".git" || name == "node_modules")) && name != "result";
};
mkBunScriptCommands =
pkgs: scripts:
let
mkBunScript =
name: script:
pkgs.writeShellApplication {
inherit name;
runtimeInputs = with pkgs; [
bun
git
];
text = ''
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root"
exec bun run ${lib.escapeShellArg script} "$@"
'';
};
in
lib.mapAttrs mkBunScript scripts;
mkBunApps =
commands:
lib.mapAttrs (name: command: {
type = "app";
program = "${command}/bin/${name}";
}) commands;
mkBunNodeModules =
system: pkgs:
pkgs.stdenvNoCC.mkDerivation {
pname = "freya-node-modules";
version = "1";
__structuredAttrs = true;
src = dependencySource;
nativeBuildInputs = with pkgs; [
bun
cacert
nodejs
];
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
GIT_SSL_CAINFO = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = nodeModulesHashes.${system};
# `patchShebangs` embeds Nix store interpreters in package bins. The
# check derivations also depend on bun/node, so this dependency blob
# can safely drop those references after its hash is verified.
unsafeDiscardReferences.out = true;
dontConfigure = true;
# Workspace package links are completed inside each check's source tree,
# so they are intentionally dangling in this dependency-only output.
dontFixup = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Keep the real workspace manifest for `--frozen-lockfile`, but
# filter out frontend workspaces that do not participate in checks.
# `--force` matters in the Nix sandbox: without it, Bun can accept
# manifest-only cached packages and leave tool binaries missing.
bun install \
--force \
--frozen-lockfile \
--ignore-scripts \
--backend copyfile \
--filter freya \
--filter '@freya/*' \
--filter '@freya/backend' \
--no-progress
patchShebangs node_modules
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
# Keep the root install in the store; checks symlink this directly.
cp -a node_modules "$out/node_modules"
# Bun also creates per-workspace node_modules directories. These are
# mostly relative symlinks, so checks copy the symlink entries into
# their writable source tree instead of symlinking the directory.
find apps packages -mindepth 2 -maxdepth 2 -type d -name node_modules -print |
while IFS= read -r node_modules_dir; do
mkdir -p "$out/$(dirname "$node_modules_dir")"
cp -a "$node_modules_dir" "$out/$node_modules_dir"
done
runHook postInstall
'';
};
mkBunCheck =
pkgs: nodeModules: name: command:
pkgs.stdenvNoCC.mkDerivation {
pname = "freya-${name}";
version = "1";
src = projectSource;
nativeBuildInputs = with pkgs; [
bun
nodejs
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Root dependencies are read-only and shared across checks.
ln -s "${nodeModules}/node_modules" node_modules
# Workspace node_modules contain relative symlinks back to packages/
# and apps/, so copy just those symlink entries into this source tree.
for node_modules_dir in "${nodeModules}"/apps/*/node_modules "${nodeModules}"/packages/*/node_modules; do
if [ -d "$node_modules_dir" ]; then
relative_path="''${node_modules_dir#"${nodeModules}/"}"
mkdir -p "$relative_path"
cp -a "$node_modules_dir/." "$relative_path/"
fi
done
${command}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
touch "$out/${name}"
runHook postInstall
'';
};
in
{
apps = forEachSystem (
system:
let
pkgs = pkgsFor.${system};
in
mkBunApps (mkBunScriptCommands pkgs appScripts)
);
checks = lib.genAttrs checkSystems (
system:
let
pkgs = pkgsFor.${system};
nodeModules = mkBunNodeModules system pkgs;
in
lib.mapAttrs (mkBunCheck pkgs nodeModules) checkCommands
);
devShells = forEachSystem (
system:
let
pkgs = pkgsFor.${system};
bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts);
commonPackages = with pkgs; [
bun
eas-cli
git
gh
gnumake
nixfmt
nodejs
openssl
pkg-config
postgresql
python3
watchman
];
linuxPackages = with pkgs; [
gcc
inotify-tools
tailscale
];
in
{
default = pkgs.mkShell {
packages =
commonPackages ++ bunScriptCommands ++ pkgs.lib.optionals pkgs.stdenv.isLinux linuxPackages;
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
shellHook = ''
export PATH="$PWD/node_modules/.bin:$PATH"
'';
};
}
);
formatter = forEachSystem (system: pkgsFor.${system}.nixfmt);
};
}

View File

@@ -10,7 +10,6 @@
"expo": "cd apps/freya-client && bun run start",
"drizzle-studio": "TS_IP=$(tailscale ip -4); echo \"Drizzle Studio: https://local.drizzle.studio/?host=${TS_IP}&port=4983\"; cd apps/freya-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983",
"freya-backend": "TS_IP=$(tailscale ip -4); echo \"Freya Backend: http://${TS_IP}:3000\"; echo \"\"; echo \"------------------ Bun Debugger ------------------\"; echo \"https://debug.bun.sh/#${TS_IP}:6499\"; echo \"------------------ Bun Debugger ------------------\"; echo \"\"; cd apps/freya-backend && bun run dev",
"client": "bun run --elide-lines=0 --filter freya-client start",
"admin-dashboard": "TS_IP=$(tailscale ip -4); echo \"Admin Dashboard: http://${TS_IP}:5174\"; cd apps/admin-dashboard && bun run dev --host 0.0.0.0",
"agent-test-cli": "cd apps/agent-test-cli && bun run start",
"test": "bun run --filter '*' test",
@@ -29,8 +28,5 @@
},
"peerDependencies": {
"typescript": "^6"
},
"patchedDependencies": {
"@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch"
}
}

View File

@@ -1,10 +0,0 @@
{
"name": "@freya/agent-protocol",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ./src"
}
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AgentEvent, AgentServerApi } from "./index"
describe("agent protocol", () => {
test("defines server methods and agent events", () => {
const server: AgentServerApi = {
async sendMessage(message) {
return { message, conversationId: "conversation-1" }
},
ping() {
return "pong"
},
}
const event: AgentEvent = { type: "message_finished" }
expect(server.ping()).toBe("pong")
expect(event.type).toBe("message_finished")
})
})

View File

@@ -1,21 +0,0 @@
export interface SendMessageResult {
message: string
conversationId: string
}
export type AgentEvent =
| { type: "conversation_started"; conversationId: string }
| { type: "message_created"; text: string }
| { type: "tool_started"; toolName: string }
| { type: "tool_finished"; toolName: string; ok: boolean }
| { type: "message_finished" }
| { type: "message_failed"; error: string }
export interface AgentServerApi {
sendMessage(message: string): Promise<SendMessageResult>
ping(): "pong"
}
export interface AgentClientApi {
notify(event: AgentEvent): void
}

View File

@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

View File

@@ -8,8 +8,7 @@
"test": "bun test ."
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"arktype": "^2.1.29"
"@standard-schema/spec": "^1.1.0"
},
"peerDependencies": {
"@json-render/core": "*",

View File

@@ -1,4 +1,4 @@
import { describe, expect, spyOn, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
@@ -145,16 +145,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
}
}
async function waitForCondition(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error("Timed out waiting for condition")
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
// =============================================================================
// TESTS
// =============================================================================
@@ -817,14 +807,11 @@ describe("FeedEngine", () => {
})
test("TTL resets after reactive update", async () => {
let now = 1_000
const nowSpy = spyOn(Date, "now").mockImplementation(() => now)
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
try {
engine.start()
// Initial reactive update
@@ -833,19 +820,15 @@ describe("FeedEngine", () => {
expect(engine.lastFeed()).not.toBeNull()
// Move past the original TTL, then trigger another update to reset it.
now += 120
// Wait 70ms (total 120ms from first update, past original TTL)
// but trigger another update at 50ms to reset TTL
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
await new Promise((resolve) => setTimeout(resolve, 50))
// Should still be cached because TTL was reset by second update.
// Should still be cached because TTL was reset by second update
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
} finally {
engine.stop()
nowSpy.mockRestore()
}
})
test("cacheTtlMs is configurable", async () => {
@@ -886,21 +869,17 @@ describe("FeedEngine", () => {
},
}
const engine = new FeedEngine({ cacheTtlMs: 20 }).register(source)
await engine.refresh()
expect(fetchCount).toBe(1)
try {
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
await waitForCondition(() => fetchCount >= 2)
// Wait for two TTL intervals to elapse
await new Promise((resolve) => setTimeout(resolve, 120))
// Should have auto-refreshed at least twice
expect(fetchCount).toBeGreaterThanOrEqual(2)
expect(engine.lastFeed()).not.toBeNull()
} finally {
engine.stop()
}
})
test("stop cancels periodic refresh", async () => {
@@ -956,25 +935,28 @@ describe("FeedEngine", () => {
},
}
const engine = new FeedEngine({ cacheTtlMs: 10_000 })
const engine = new FeedEngine({ cacheTtlMs: 100 })
.register(location)
.register(countingWeather)
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
try {
engine.start()
// At 40ms, push a reactive update — this resets the timer
await new Promise((resolve) => setTimeout(resolve, 40))
const countBeforeUpdate = fetchCount
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await waitForCondition(() => fetchCount > countBeforeUpdate && engine.lastFeed() !== null)
await new Promise((resolve) => setTimeout(resolve, 20))
// Reactive updates refresh the cache and reset the pending periodic timer.
// Reactive update triggered a fetch
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
expect(clearTimeoutSpy).toHaveBeenCalled()
} finally {
const countAfterUpdate = fetchCount
// At 100ms from start (60ms after reactive update), the original
// timer would have fired, but it was reset. No extra fetch yet.
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchCount).toBe(countAfterUpdate)
engine.stop()
clearTimeoutSpy.mockRestore()
}
})
})

View File

@@ -1,84 +1,50 @@
// Context
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context";
export { Context, contextKey, serializeKey } from "./context";
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
export { Context, contextKey, serializeKey } from "./context"
// Actions
export type { ActionDefinition } from "./action";
export { UnknownActionError } from "./action";
// Conversation
export {
AssistantMessagePayload,
AttachmentPayload,
AttachmentType,
ContextSummary,
ContextSummaryPayload,
ConversationEntryKind,
ConversationEntryMetadata,
ConversationEntryVisibility,
GenericObjectPayload,
JsonMessagePart,
MessagePart,
ModelRunMetadata,
TextMessagePart,
UserMessagePayload,
ConversationEntryPayload,
} from "./conversation";
export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type {
FeedItem,
FeedItemRenderer,
FeedItemSignals,
RenderedFeedItem,
Slot,
} from "./feed";
export { TimeRelevance } from "./feed";
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source";
export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type {
FeedEnhancement,
FeedPostProcessor,
ItemGroup,
} from "./feed-post-processor";
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine
export type {
FeedEngineConfig,
FeedResult,
FeedSubscriber,
SourceError,
} from "./feed-engine";
export { FeedEngine } from "./feed-engine";
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"
// =============================================================================
// DEPRECATED - Use FeedSource + FeedEngine instead
// =============================================================================
// Data Source (deprecated - use FeedSource)
export type { DataSource } from "./data-source";
export type { DataSource } from "./data-source"
// Context Provider (deprecated - use FeedSource)
export type { ContextProvider } from "./context-provider";
export type { ContextProvider } from "./context-provider"
// Context Bridge (deprecated - use FeedEngine)
export type { ProviderError, RefreshResult } from "./context-bridge";
export { ContextBridge } from "./context-bridge";
export type { ProviderError, RefreshResult } from "./context-bridge"
export { ContextBridge } from "./context-bridge"
// Reconciler (deprecated - use FeedEngine)
export type {
ReconcileResult,
ReconcilerConfig,
SourceError as ReconcilerSourceError,
} from "./reconciler";
export { Reconciler } from "./reconciler";
} from "./reconciler"
export { Reconciler } from "./reconciler"
// Feed Controller (deprecated - use FeedEngine)
export type {
FeedControllerConfig,
FeedSubscriber as FeedControllerSubscriber,
} from "./feed-controller";
export { FeedController } from "./feed-controller";
} from "./feed-controller"
export { FeedController } from "./feed-controller"

View File

@@ -1,23 +0,0 @@
diff --git a/out/shared/disjoint.js b/out/shared/disjoint.js
index 9fb94ad4f257b95225536eb5fdf20eaf6193b7a6..377af13b28807b1943f14240c1e889c3b16efe94 100644
--- a/out/shared/disjoint.js
+++ b/out/shared/disjoint.js
@@ -48,11 +48,17 @@ export class Disjoint extends Array {
return result;
}
withPrefixKey(key, kind) {
- return this.map(entry => ({
+ const result = this.map(entry => ({
...entry,
path: [key, ...entry.path],
optional: entry.optional || kind === "optional"
}));
+ // Workaround for Static Hermes, which doesn't preserve the Disjoint Array subclass here.
+ // Mirrors the existing invert() guard added for https://github.com/arktypeio/arktype/issues/1027
+ // and covers the same failure mode reported in https://github.com/arktypeio/arktype/issues/1415.
+ if (!(result instanceof Disjoint))
+ return new Disjoint(...result);
+ return result;
}
toNeverIfDisjoint() {
return $ark.intrinsic.never;

View File

@@ -1,18 +0,0 @@
# Dependency Patches
## `@ark/schema@0.56.0`
`@ark/schema` is patched for React Native/Hermes compatibility.
ArkType's internal `Disjoint` type extends `Array`. In Hermes, `Array.prototype.map()`
does not always preserve the subclass instance. If `Disjoint.withPrefixKey()` returns
a plain array, later ArkType reduction code can call schema methods such as `isRoot()`
on that array and crash during app startup.
The patch mirrors ArkType's existing guard in `Disjoint.invert()` by wrapping the
mapped result back into `new Disjoint(...)` when Hermes returns a plain array.
Upstream context:
- https://github.com/arktypeio/arktype/issues/1415
- https://github.com/arktypeio/arktype/issues/1027

View File

@@ -1,11 +0,0 @@
{
"version": 1,
"skills": {
"upgrading-expo": {
"source": "expo/skills",
"sourceType": "github",
"skillPath": "plugins/expo/skills/upgrading-expo/SKILL.md",
"computedHash": "98d228925a442126789d90783fdd4ae5de33ab690024b575698057d2ffc44b40"
}
}
}