Compare commits

...

36 Commits

Author SHA1 Message Date
4b824c66ce feat(tfl): add FeedItemRenderer for TfL alerts (#73)
* feat(tfl): add FeedItemRenderer for TfL alerts

Implement renderTflAlert using JRX and @aelis/components.
Upgrade @nym.sh/jrx to 0.2.0 for null child support.

Co-authored-by: Ona <no-reply@ona.com>

* fix(tfl): add jsxImportSource pragma for CI

The CI test runner doesn't use per-package tsconfig.json,
so the pragma is needed alongside the tsconfig setting.

Co-authored-by: Ona <no-reply@ona.com>

* fix(ci): run tests per-package via bun run test

Use 'bun run test' (which runs 'bun run --filter * test')
instead of 'bun test' so each package runs tests from its
own directory. Add jsxImportSource pragma to renderer files
since consumers without a JRX tsconfig also import them.

Co-authored-by: Ona <no-reply@ona.com>

* fix(tfl): handle near-1km boundary in formatDistance

Values like 0.9999km rounded to 1000m and displayed as
'1000m away'. Now converts to meters first and switches
to km format when rounded meters >= 1000.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 00:20:54 +00:00
272fb9b9b3 feat(caldav): add FeedItemRenderer (#74)
Implement renderCalDavFeedItem using JRX JSX to render
CalDAV events as FeedCard components. Bump @nym.sh/jrx
to 0.2.0 for null/undefined child support.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 00:19:44 +00:00
5ea24b0a13 feat(core): add sourceId to FeedItem (#72)
Each FeedSource implementation now sets sourceId on items
it produces, allowing consumers to trace items back to
their originating source.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 23:51:41 +00:00
bed033652c ci: add docker build workflow for waitlist website (#71)
* ci: add docker build workflow for waitlist website

Builds and pushes to cr.nym.sh on pushes to master
that touch apps/waitlist-website/.

Co-authored-by: Ona <no-reply@ona.com>

* ci: rename image to aelis-waitlist-website

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 23:24:07 +00:00
ec083c3c77 feat(client): add Button.Icon subcomponent (#70)
Introduce Button.Icon to enforce consistent icon styling
(size, theme-aware color) instead of hardcoding Feather
props at each call site. Update showcase and json-render
registry to use it.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 00:39:59 +00:00
45fa539d3e feat(core): add FeedItemRenderer type (#69)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 00:06:24 +00:00
b4ad910a14 feat: add @aelis/components package with JRX definitions (#68)
JRX component wrappers for the aelis-client UI components,
enabling server-side feed item rendering via json-render.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 23:57:54 +00:00
d3452dd452 feat(client): add json-render catalog and registry (#67)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 23:56:34 +00:00
c78ad25f0d feat(client): add component library and simplify routing (#66)
* feat(client): add component library and simplify routing

Remove tab layout, explore page, modal, and unused template
components. Replace with single-page layout and a dev component
showcase with per-component detail pages.

- Add Button with label prop, leading/trailing icon support
- Add FeedCard, SerifText, SansSerifText, MonospaceText
- Add colocated *.showcase.tsx files for each component
- Use Stack navigator with themed headers

Co-authored-by: Ona <no-reply@ona.com>

* fix(client): render showcase as JSX component

Co-authored-by: Ona <no-reply@ona.com>

* chore(client): remove dead code chain

Remove ThemedText, useThemeColor, useColorScheme hook,
Colors, and Fonts — none referenced by current screens.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 00:23:06 +00:00
e07157eba0 feat(backend): add GET /api/context endpoint (#65)
* feat(backend): add GET /api/context endpoint

Query context values by key with exact/prefix match
support. Default mode tries exact first, falls back
to prefix.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): validate context key element types

Reject booleans, nulls, and nested arrays in the key
param. Only string, number, and plain objects with
primitive values are accepted.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-13 00:17:54 +00:00
3036f4ad3f refactor(backend): rename feed dir to engine (#64)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-12 00:57:32 +00:00
f2c991eebb feat(core): add RenderedFeedItem type with JRX UI support (#63)
Add RenderedFeedItem that extends FeedItem with a ui: JrxNode field
for client-side rendering. Add @nym.sh/jrx and @json-render/core as
peer dependencies on @aelis/core.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-11 23:31:32 +00:00
805e4f2bc6 feat(backend): bypass auth in development (#62)
Use mockAuthSessionMiddleware with a fully populated dev
user when NODE_ENV is not production. Auth handlers are
only registered in production.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-11 00:21:34 +00:00
34ead53e1d feat(caldav): add slot support for feed items (#57)
Adds three LLM-fillable slots to every CalDav feed item:
insight, preparation, and crossSource. Slot prompts are
stored in separate .txt files under src/prompts/ with
few-shot examples to steer the LLM away from restating
event details.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-10 19:36:34 +00:00
863c298bd3 refactor: rename aris to aelis (#59)
Rename all references across the codebase: package names,
imports, source IDs, directory names, docs, and configs.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-10 19:19:23 +00:00
230116d9f7 fix(waitlist): add delay before email to avoid rate limit (#61)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 03:35:58 +00:00
0a08706cf9 feat: init waitlist website (#60)
* feat: init waitlist website

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: tweak copy

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: reminify lottie json

Co-authored-by: Ona <no-reply@ona.com>

* feat[waitlist]: seo and preview stuff

* chore[waitlist]: clean up

* build[waitlist]: add fly.io config

* feat(waitlist): add time-of-day greeting and duplicate email message

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): handle duplicate emails and send confirmation

Co-authored-by: Ona <no-reply@ona.com>

* chore: remove stray console.log

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer with bottom progressive blur

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add trouble message and improve error handling

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): fix timeOfDay logic, typo, and add audienceId

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add .ico fallback favicon and style error page

Co-authored-by: Ona <no-reply@ona.com>

* chore(waitlist): add robots.txt, sitemap, clean dockerignore

Co-authored-by: Ona <no-reply@ona.com>

* feat(waitlist): add footer to privacy policy page

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): use segments instead of audienceId

Co-authored-by: Ona <no-reply@ona.com>

* fix[waitlist]: remove segmentId from dup check

Co-authored-by: Ona <no-reply@ona.com>

* fix(waitlist): reset logo animation on mouse leave

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 02:54:56 +00:00
badc00c43b feat(backend): add LLM-powered feed enhancement (#58)
* feat(backend): add LLM-powered feed enhancement

Add enhancement harness that fills feed item slots and
generates synthetic items via OpenRouter.

- LLM client with 30s timeout, reusable SDK instance
- Prompt builder with mini calendar and week overview
- arktype schema validation + JSON Schema for structured output
- Pure merge function with clock injection
- Defensive fallback in feed endpoint on enhancement failure
- Skips LLM call when no unfilled slots or no API key

Co-authored-by: Ona <no-reply@ona.com>

* refactor: move feed enhancement into UserSession

Move enhancement logic from HTTP handler into UserSession so the
transport layer has no knowledge of enhancement. UserSession.feed()
handles refresh, enhancement, and caching in one place.

- UserSession subscribes to engine updates and re-enhances eagerly
- Enhancement cache tracks source identity to prevent stale results
- UserSessionManager accepts config object with optional enhancer
- HTTP handler simplified to just call session.feed()

Co-authored-by: Ona <no-reply@ona.com>

* test: add schema sync tests for arktype/JSON Schema drift

Validates reference payloads against both the arktype schema
(parseEnhancementResult) and the OpenRouter JSON Schema structure.
Catches field additions/removals or type changes in either schema.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: rename arktype schemas to match types

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
31d5aa8d50 fix(caldav): expand recurring events in range (#55)
The iCal parser returned master VEVENT components with their
original start dates instead of expanding recurrences. Events
from months ago appeared in today's feed.

parseICalEvents now accepts an optional timeRange. When set,
recurring events are expanded via ical.js iterator and only
occurrences overlapping the range are returned. Exception
overrides (RECURRENCE-ID) are applied during expansion.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-04 23:17:14 +00:00
de29e44a08 feat(source-weatherkit): add insight slot (#54)
Add LLM-fillable insight slot to weather-current feed items.
Prompt lives in a separate .txt file for easy iteration.

Also adds interactive CLI script (scripts/query.ts) for
querying WeatherKit with credential caching and JSON output.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-03 00:00:11 +00:00
caf48484bf feat(core): add Slot type and slots field to FeedItem (#53)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 23:57:51 +00:00
ac80e0cdac feat: add TimeOfDayEnhancer post-processor (#52)
* feat: add TimeOfDayEnhancer post-processor

Rule-based feed post-processor that reranks items
by time period, day type, and calendar proximity.

New package: @aris/feed-enhancers

Co-authored-by: Ona <no-reply@ona.com>

* fix: clamp boost values to [-1, 1]

Additive layers can exceed the documented range.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance consts instead of strings

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 23:06:16 +00:00
96e22e227c feat: replace flat context with tuple-keyed store (#50)
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:52:41 +00:00
8ca8a0d1d2 fix: use PascalCase for FeedItemType members (#51)
Rename camelCase members to PascalCase in WeatherFeedItemType
and CalendarFeedItemType to match TflFeedItemType and
CalDavFeedItemType conventions.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:10:34 +00:00
4c9ac2c61a feat(tfl): export TflFeedItemType const (#47)
Replace hardcoded "tfl-alert" string with a
TflFeedItemType const object, matching the pattern
used by google-calendar and weatherkit packages.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:43:27 +00:00
be3fc41a00 refactor(google-calendar): remove redundant type aliases (#48)
The *TypeType re-exports are unnecessary since
consumers can use import type to get the type.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:43:08 +00:00
2e9c600e93 refactor(weatherkit): remove redundant type aliases (#49)
The *TypeType re-exports are unnecessary since
consumers can use import type to get the type.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:42:58 +00:00
d616fd52d3 feat(caldav): export CalDavFeedItemType const (#46)
Replace hardcoded "caldav-event" string with a
CalDavFeedItemType const object, matching the pattern
used by google-calendar and weatherkit packages.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 18:42:40 +00:00
2d7544500d feat: add boost directive to FeedEnhancement (#45)
* feat: add boost directive to FeedEnhancement

Post-processors can now return a boost map (item ID -> score)
to promote or demote items in the feed ordering. Scores from
multiple processors are summed and clamped to [-1, 1].

Co-authored-by: Ona <no-reply@ona.com>

* fix: correct misleading sort order comments

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:26:25 +00:00
9dc0cc3d2f feat: add GPG commit signing skill (#44)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:21:30 +00:00
fe1d261f56 feat: pass context to feed post-processors (#43)
Post-processors now receive Context as their 2nd parameter,
allowing them to use contextual data (time, location, etc.)
when producing enhancements.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:10:55 +00:00
40ad90aa2d feat: add generic CalDAV calendar data source (#42)
* feat: add generic CalDAV calendar data source

Add @aris/source-caldav package that fetches calendar events from any
CalDAV server via tsdav + ical.js.

- Supports Basic auth and OAuth via explicit authMethod discriminant
- serverUrl provided at construction time, not hardcoded
- Optional timeZone for correct local day boundaries
- Credentials cleared from memory after client login
- Failed calendar fetches logged, not silently dropped
- Login promise cached with retry on failure

Co-authored-by: Ona <no-reply@ona.com>

* fix: deduplicate concurrent fetchEvents calls

Co-authored-by: Ona <no-reply@ona.com>

* fix: timezone-aware signals, low-priority cancelled events

- computeSignals uses startOfDay(timeZone) for 'later today' boundary
- Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 16:09:11 +00:00
82ac2b577d feat: add post-processor pipeline to FeedEngine (#41)
* feat: add post-processor pipeline to FeedEngine

Add FeedPostProcessor type and FeedEnhancement interface.
Post-processors run after item collection on all update
paths (refresh, reactive context, reactive items).

Pipeline is chained — each processor sees items as modified
by the previous one. Enhancement merging handles additional
items, suppression, and grouped items. Throwing processors
are caught and recorded in FeedResult.errors.

Co-authored-by: Ona <no-reply@ona.com>

* docs: document intentional TItems cast in post-processor merge

Co-authored-by: Ona <no-reply@ona.com>

* fix: filter stale item IDs from groups after pipeline

Groups accumulated during the pipeline can reference items
that a later processor suppressed. The engine now strips
stale IDs and drops empty groups before returning.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: use reduce for stale group filtering

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:57:01 +00:00
ffea38b986 fix: remove apple calendar data source (#40)
The CalDAV-based approach doesn't work as expected for
Apple Calendar integration.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:30:23 +00:00
28d26b3c87 Replace FeedItem.priority with signals (#39)
* feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance enum in all tests

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:57 +00:00
78b0ed94bd docs: update UI rendering to server-driven twrnc (#38)
Replace outdated UI Registry model with server-driven
json-render + twrnc approach. Update architecture diagram,
terminology (DataSource→FeedSource, Reconciler→FeedEngine),
and design principles to match current codebase.

Add ui, slots fields to FeedItem in actions spec. Add
Spotify example with twrnc className-based ui tree.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:55:46 +00:00
286 changed files with 11352 additions and 3076 deletions

View File

@@ -0,0 +1,43 @@
---
name: gpg-commit-signing
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
---
# GPG Commit Signing
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
## Workflow
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
```bash
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
```
If not set, skip signing — commit without `-S`.
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
```bash
git commit -S -m "message"
```
If this succeeds, no further steps are needed.
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
```bash
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
chmod +x /tmp/gpg-sign.sh
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
rm /tmp/gpg-sign.sh
```
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
## Anti-patterns
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
- Do not leave wrapper scripts on disk after committing.

View File

@@ -0,0 +1,42 @@
name: Build waitlist website
on:
push:
branches: [master]
paths:
- apps/waitlist-website/**
- .github/workflows/build-waitlist-website.yml
workflow_dispatch:
env:
REGISTRY: cr.nym.sh
IMAGE_NAME: aelis-waitlist-website
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: apps/waitlist-website
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -21,4 +21,4 @@ jobs:
run: bun install --frozen-lockfile
- name: Run tests
run: bun test
run: bun run test

View File

@@ -1,8 +1,8 @@
services:
expo:
name: Expo Dev Server
description: Expo development server for aris-client
description: Expo development server for aelis-client
triggeredBy:
- postDevcontainerStart
commands:
start: cd apps/aris-client && ./scripts/run-dev-server.sh
start: cd apps/aelis-client && ./scripts/run-dev-server.sh

View File

@@ -2,7 +2,7 @@
## Project
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
## Commands

View File

@@ -1,4 +1,4 @@
# aris
# aelis
To install dependencies:
@@ -8,14 +8,14 @@ bun install
## Packages
### @aris/source-tfl
### @aelis/source-tfl
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
#### Testing
```bash
cd packages/aris-source-tfl
cd packages/aelis-source-tfl
bun run test
```

View File

@@ -7,6 +7,11 @@ BETTER_AUTH_SECRET=
# Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000
# OpenRouter (LLM feed enhancement)
OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY=
WEATHERKIT_KEY_ID=

View File

@@ -1,5 +1,5 @@
{
"name": "@aris/backend",
"name": "@aelis/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
@@ -9,10 +9,13 @@
"test": "bun test src/"
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@aelis/core": "workspace:*",
"@aelis/source-caldav": "workspace:*",
"@aelis/source-google-calendar": "workspace:*",
"@aelis/source-location": "workspace:*",
"@aelis/source-tfl": "workspace:*",
"@aelis/source-weatherkit": "workspace:*",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",

View File

@@ -61,7 +61,7 @@ export async function getSessionFromHeaders(
}
/**
* Test-only middleware that injects a fake user and session.
* Dev/test middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401.
*/
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
@@ -69,8 +69,34 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
if (!userId) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", { id: userId } as AuthUser)
c.set("session", { id: "mock-session" } as AuthSession)
const now = new Date()
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
const user: AuthUser = {
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
name: "Dev User",
email: "dev@aelis.local",
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
}
const session: AuthSession = {
id: "Wt3FvBpXaQrMhD8sKjE6LcYn0gUz5iRo",
userId: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
expiresAt,
ipAddress: "127.0.0.1",
userAgent: "aelis-dev",
createdAt: now,
updatedAt: now,
}
c.set("user", user)
c.set("session", session)
await next()
}
}

View File

@@ -0,0 +1,315 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { contextKey } from "@aelis/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
interface FeedResponse {
items: Array<{
id: string
type: string
priority: number
timestamp: string
data: Record<string, unknown>
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(
id: string,
items: FeedItem[] = [],
contextEntries: readonly ContextEntry[] | null = null,
): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return contextEntries
},
async fetchItems() {
return items
},
}
}
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
const app = new Hono()
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
expect(res.status).toBe(401)
})
test("returns cached feed when available", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("item-1")
expect(body.items[0]!.type).toBe("test")
expect(body.items[0]!.priority).toBe(0.8)
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
expect(body.errors).toHaveLength(0)
})
test("forces refresh when no cached feed", async () => {
const items: FeedItem[] = [
{
id: "fresh-1",
sourceId: "test",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("fresh-1")
expect(body.items[0]!.data.fresh).toBe(true)
expect(body.errors).toHaveLength(0)
})
test("serializes source errors as message strings", async () => {
const failingSource: FeedSource = {
id: "failing",
async listActions() {
return {}
},
async executeAction() {
return undefined
},
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({ providers: [() => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
})
describe("GET /api/context", () => {
const weatherKey = contextKey("aelis.weather", "weather")
const weatherData = { temperature: 20, condition: "Clear" }
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
// The mock auth middleware always injects this hardcoded user ID
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
function buildContextApp(userId?: string) {
const manager = new UserSessionManager({
providers: [() => createStubSource("weather", [], contextEntries)],
})
const app = buildTestApp(manager, userId)
const session = manager.getOrCreate(mockUserId)
return { app, session }
}
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(401)
})
test("returns 400 when key param is missing", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is invalid JSON", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=notjson")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is not an array", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request('/api/context?key="string"')
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key contains invalid element types", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=[true,null,[1,2]]")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when key is an empty array", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=[]")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("key")
})
test("returns 400 when match param is invalid", async () => {
const { app } = buildContextApp("user-1")
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("match")
})
test("returns exact match with match=exact", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
expect(body.match).toBe("exact")
expect(body.value).toEqual(weatherData)
})
test("returns 404 with match=exact when only prefix would match", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
expect(res.status).toBe(404)
})
test("returns prefix match with match=prefix", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
expect(res.status).toBe(200)
const body = (await res.json()) as {
match: string
entries: Array<{ key: unknown[]; value: unknown }>
}
expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
expect(body.entries[0]!.value).toEqual(weatherData)
})
test("default mode returns exact match when available", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
expect(body.match).toBe("exact")
expect(body.value).toEqual(weatherData)
})
test("default mode falls back to prefix when no exact match", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as {
match: string
entries: Array<{ key: unknown[]; value: unknown }>
}
expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.value).toEqual(weatherData)
})
test("returns 404 when neither exact nor prefix matches", async () => {
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["nonexistent"]')
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Context key not found")
})
})

View File

@@ -0,0 +1,118 @@
import type { Context, Hono } from "hono"
import { contextKey } from "@aelis/core"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
type Env = {
Variables: {
sessionManager: UserSessionManager
}
}
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerFeedHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = await session.feed()
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}
function handleGetContext(c: Context<Env>) {
const keyParam = c.req.query("key")
if (!keyParam) {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
let parsed: unknown
try {
parsed = JSON.parse(keyParam)
} catch {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
}
const matchParam = c.req.query("match")
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
}
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const context = session.engine.currentContext()
const key = contextKey(...parsed)
if (matchParam === "exact") {
const value = context.get(key)
if (value === undefined) {
return c.json({ error: "Context key not found" }, 404)
}
return c.json({ match: "exact", value })
}
if (matchParam === "prefix") {
const entries = context.find(key)
if (entries.length === 0) {
return c.json({ error: "Context key not found" }, 404)
}
return c.json({ match: "prefix", entries })
}
// Default: single find() covers both exact and prefix matches
const entries = context.find(key)
if (entries.length === 0) {
return c.json({ error: "Context key not found" }, 404)
}
// If exactly one result with the same key length, treat as exact match
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
return c.json({ match: "exact", value: entries[0]!.value })
}
return c.json({ match: "prefix", entries })
}
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
function isContextKeyPart(value: unknown): boolean {
if (typeof value === "string" || typeof value === "number") {
return true
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return Object.values(value).every(
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
)
}
return false
}

View File

@@ -0,0 +1,51 @@
import type { FeedItem } from "@aelis/core"
import type { LlmClient } from "./llm-client.ts"
import { mergeEnhancement } from "./merge.ts"
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
/** Takes feed items, returns enhanced feed items. */
export type FeedEnhancer = (items: FeedItem[]) => Promise<FeedItem[]>
export interface FeedEnhancerConfig {
client: LlmClient
/** Defaults to Date.now — override for testing */
clock?: () => Date
}
/**
* Creates a FeedEnhancer that uses the provided LlmClient.
*
* Skips the LLM call when no items have unfilled slots.
* Returns items unchanged on LLM failure.
*/
export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
const { client } = config
const clock = config.clock ?? (() => new Date())
return async function enhanceFeed(items) {
if (!hasUnfilledSlots(items)) {
return items
}
const currentTime = clock()
const { systemPrompt, userMessage } = buildPrompt(items, currentTime)
let result
try {
result = await client.enhance({ systemPrompt, userMessage })
} catch (err) {
console.error("[enhancement] LLM call failed:", err)
result = null
}
if (!result) {
return items
}
return mergeEnhancement(items, result, currentTime)
}
}

View File

@@ -0,0 +1,71 @@
import { OpenRouter } from "@openrouter/sdk"
import type { EnhancementResult } from "./schema.ts"
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
const DEFAULT_TIMEOUT_MS = 30_000
export interface LlmClientConfig {
apiKey: string
model?: string
timeoutMs?: number
}
export interface LlmClientRequest {
systemPrompt: string
userMessage: string
}
export interface LlmClient {
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
}
/**
* Creates a reusable LLM client backed by OpenRouter.
* The OpenRouter SDK instance is created once and reused across calls.
*/
export function createLlmClient(config: LlmClientConfig): LlmClient {
const client = new OpenRouter({
apiKey: config.apiKey,
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
})
const model = config.model ?? DEFAULT_MODEL
return {
async enhance(request) {
const response = await client.chat.send({
chatGenerationParams: {
model,
messages: [
{ role: "system" as const, content: request.systemPrompt },
{ role: "user" as const, content: request.userMessage },
],
responseFormat: {
type: "json_schema" as const,
jsonSchema: {
name: "enhancement_result",
strict: true,
schema: enhancementResultJsonSchema,
},
},
stream: false,
},
})
const content = response.choices?.[0]?.message?.content
if (typeof content !== "string") {
console.warn("[enhancement] LLM returned no content in response")
return null
}
const result = parseEnhancementResult(content)
if (!result) {
console.warn("[enhancement] Failed to parse LLM response:", content)
}
return result
},
}
}

View File

@@ -0,0 +1,151 @@
import type { FeedItem } from "@aelis/core"
import { describe, expect, test } from "bun:test"
import type { EnhancementResult } from "./schema.ts"
import { mergeEnhancement } from "./merge.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },
...overrides,
}
}
const now = new Date("2025-06-01T12:00:00Z")
describe("mergeEnhancement", () => {
test("fills matching slots", () => {
const item = makeItem({
slots: {
insight: { description: "Weather insight", content: null },
},
})
const result: EnhancementResult = {
slotFills: {
"item-1": { insight: "Rain after 3pm" },
},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]!.slots!.insight!.content).toBe("Rain after 3pm")
// Description preserved
expect(merged[0]!.slots!.insight!.description).toBe("Weather insight")
})
test("does not mutate original items", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { insight: "filled" } },
syntheticItems: [],
}
mergeEnhancement([item], result, now)
expect(item.slots!.insight!.content).toBeNull()
})
test("ignores fills for non-existent items", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: { "non-existent": { insight: "text" } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]!.id).toBe("item-1")
})
test("ignores fills for non-existent slots", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { "non-existent-slot": "text" } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]!.slots!.insight!.content).toBeNull()
})
test("skips null fills", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
const result: EnhancementResult = {
slotFills: { "item-1": { insight: null } },
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]!.slots!.insight!.content).toBeNull()
})
test("passes through items without slots unchanged", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged[0]).toBe(item)
})
test("appends synthetic items with backfilled fields", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [
{
id: "briefing-morning",
type: "briefing",
text: "Light afternoon ahead.",
},
],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(2)
expect(merged[1]!.id).toBe("briefing-morning")
expect(merged[1]!.type).toBe("briefing")
expect(merged[1]!.timestamp).toEqual(now)
expect(merged[1]!.data).toEqual({ text: "Light afternoon ahead." })
})
test("handles empty enhancement result", () => {
const item = makeItem()
const result: EnhancementResult = {
slotFills: {},
syntheticItems: [],
}
const merged = mergeEnhancement([item], result, now)
expect(merged).toHaveLength(1)
expect(merged[0]).toBe(item)
})
})

View File

@@ -0,0 +1,48 @@
import type { FeedItem } from "@aelis/core"
import type { EnhancementResult } from "./schema.ts"
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
/**
* Merges an EnhancementResult into feed items.
*
* - Writes slot content from slotFills into matching items
* - Appends synthetic items to the list
* - Returns a new array (no mutation)
* - Ignores fills for items/slots that don't exist
*/
export function mergeEnhancement(
items: FeedItem[],
result: EnhancementResult,
currentTime: Date,
): FeedItem[] {
const merged = items.map((item) => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item
const mergedSlots = { ...item.slots }
let changed = false
for (const [slotName, content] of Object.entries(fills)) {
if (slotName in mergedSlots && content !== null) {
mergedSlots[slotName] = { ...mergedSlots[slotName]!, content }
changed = true
}
}
return changed ? { ...item, slots: mergedSlots } : item
})
for (const synthetic of result.syntheticItems) {
merged.push({
id: synthetic.id,
sourceId: ENHANCEMENT_SOURCE_ID,
type: synthetic.type,
timestamp: currentTime,
data: { text: synthetic.text },
})
}
return merged
}

View File

@@ -0,0 +1,170 @@
import type { FeedItem } from "@aelis/core"
import { describe, expect, test } from "bun:test"
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
return {
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00Z"),
data: { value: 42 },
...overrides,
}
}
function parseUserMessage(userMessage: string): Record<string, unknown> {
return JSON.parse(userMessage)
}
describe("hasUnfilledSlots", () => {
test("returns false for items without slots", () => {
expect(hasUnfilledSlots([makeItem()])).toBe(false)
})
test("returns false for items with all slots filled", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: "filled" },
},
})
expect(hasUnfilledSlots([item])).toBe(false)
})
test("returns true when at least one slot is unfilled", () => {
const item = makeItem({
slots: {
insight: { description: "test", content: null },
},
})
expect(hasUnfilledSlots([item])).toBe(true)
})
test("returns false for empty array", () => {
expect(hasUnfilledSlots([])).toBe(false)
})
})
describe("buildPrompt", () => {
test("puts items with unfilled slots in items", () => {
const item = makeItem({
slots: {
insight: { description: "Weather insight", content: null },
filled: { description: "Already done", content: "done" },
},
})
const { userMessage } = buildPrompt([item], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.items).toHaveLength(1)
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
insight: "Weather insight",
})
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
expect(parsed.context).toHaveLength(0)
})
test("puts slotless items in context", () => {
const withSlots = makeItem({
id: "with-slots",
slots: { insight: { description: "test", content: null } },
})
const withoutSlots = makeItem({ id: "no-slots" })
const { userMessage } = buildPrompt([withSlots, withoutSlots], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.items).toHaveLength(1)
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("with-slots")
expect(parsed.context).toHaveLength(1)
expect((parsed.context as Array<Record<string, unknown>>)[0]!.id).toBe("no-slots")
})
test("includes time in ISO format", () => {
const { userMessage } = buildPrompt([], new Date("2025-06-01T12:00:00Z"))
const parsed = parseUserMessage(userMessage)
expect(parsed.time).toBe("2025-06-01T12:00:00.000Z")
})
test("system prompt is non-empty", () => {
const { systemPrompt } = buildPrompt([], new Date())
expect(systemPrompt.length).toBeGreaterThan(0)
})
test("includes schedule in system prompt", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Team standup",
startDate: "2025-06-01T10:00:00Z",
endDate: "2025-06-01T10:30:00Z",
isAllDay: false,
location: null,
},
slots: {
insight: { description: "test", content: null },
},
})
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Schedule:\n")
expect(systemPrompt).toContain("Team standup")
expect(systemPrompt).toContain("10:00")
})
test("includes location in schedule", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Therapy",
startDate: "2025-06-02T18:00:00Z",
endDate: "2025-06-02T19:00:00Z",
isAllDay: false,
location: "92 Tooley Street, London",
},
})
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Therapy @ 92 Tooley Street, London")
})
test("includes week calendar but omits schedule when no calendar items", () => {
const weatherItem = makeItem({
type: "weather-current",
data: { temperature: 14 },
})
const { systemPrompt } = buildPrompt([weatherItem], new Date("2025-06-01T12:00:00Z"))
expect(systemPrompt).toContain("Week:")
expect(systemPrompt).not.toContain("Schedule:")
})
test("user message is pure JSON", () => {
const calEvent = makeItem({
id: "cal-1",
type: "caldav-event",
data: {
title: "Budget Review",
startTime: "2025-06-01T14:00:00Z",
endTime: "2025-06-01T15:00:00Z",
isAllDay: false,
location: "https://meet.google.com/abc",
},
})
const { userMessage } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
expect(userMessage.startsWith("{")).toBe(true)
expect(() => JSON.parse(userMessage)).not.toThrow()
})
})

View File

@@ -0,0 +1,218 @@
import type { FeedItem } from "@aelis/core"
import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import systemPromptBase from "./prompts/system.txt"
const CALENDAR_ITEM_TYPES = new Set<string>([
CalDavFeedItemType.Event,
CalendarFeedItemType.Event,
CalendarFeedItemType.AllDay,
])
/**
* Builds the system prompt and user message for the enhancement harness.
*
* Includes a pre-computed mini calendar so the LLM doesn't have to
* parse timestamps to understand the user's schedule.
*/
export function buildPrompt(
items: FeedItem[],
currentTime: Date,
): { systemPrompt: string; userMessage: string } {
const schedule = buildSchedule(items, currentTime)
const enhanceItems: Array<{
id: string
data: Record<string, unknown>
slots: Record<string, string>
}> = []
const contextItems: Array<{
id: string
type: string
data: Record<string, unknown>
}> = []
for (const item of items) {
const hasUnfilledSlots =
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null)
if (hasUnfilledSlots) {
enhanceItems.push({
id: item.id,
data: item.data,
slots: Object.fromEntries(
Object.entries(item.slots!)
.filter(([, slot]) => slot.content === null)
.map(([name, slot]) => [name, slot.description]),
),
})
} else {
contextItems.push({
id: item.id,
type: item.type,
data: item.data,
})
}
}
const userMessage = JSON.stringify({
time: currentTime.toISOString(),
items: enhanceItems,
context: contextItems,
})
const weekCalendar = buildWeekCalendar(currentTime)
let systemPrompt = systemPromptBase
systemPrompt += `\n\nWeek:\n${weekCalendar}`
if (schedule) {
systemPrompt += `\n\nSchedule:\n${schedule}`
}
return { systemPrompt, userMessage }
}
/**
* Returns true if any item has at least one unfilled slot.
*/
export function hasUnfilledSlots(items: FeedItem[]): boolean {
return items.some(
(item) =>
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null),
)
}
// -- Helpers --
interface CalendarEntry {
date: Date
title: string
location: string | null
isAllDay: boolean
startTime: Date
endTime: Date
}
function toValidDate(value: unknown): Date | null {
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
return null
}
function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
if (!CALENDAR_ITEM_TYPES.has(item.type)) return null
const d = item.data
const title = d.title
if (typeof title !== "string" || !title) return null
// CalDAV uses startDate/endDate, Google Calendar uses startTime/endTime
const startTime = toValidDate(d.startDate ?? d.startTime)
if (!startTime) return null
const endTime = toValidDate(d.endDate ?? d.endTime) ?? startTime
return {
date: startTime,
title,
location: typeof d.location === "string" ? d.location : null,
isAllDay: typeof d.isAllDay === "boolean" ? d.isAllDay : false,
startTime,
endTime,
}
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
function pad2(n: number): string {
return n.toString().padStart(2, "0")
}
function formatTime(date: Date): string {
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`
}
function formatDayShort(date: Date): string {
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
}
function formatDayLabel(date: Date, currentTime: Date): string {
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))
const dayName = formatDayShort(date)
if (diffDays === 0) return `Today: ${dayName}`
if (diffDays === 1) return `Tomorrow: ${dayName}`
return dayName
}
/**
* Builds a week overview mapping day names to dates,
* so the LLM can easily match ISO timestamps to days.
*/
function buildWeekCalendar(currentTime: Date): string {
const lines: string[] = []
for (let i = 0; i < 7; i++) {
const date = new Date(currentTime)
date.setUTCDate(date.getUTCDate() + i)
const label = i === 0 ? "Today" : i === 1 ? "Tomorrow" : ""
const dayStr = formatDayShort(date)
const iso = date.toISOString().slice(0, 10)
const prefix = label ? `${label}: ` : ""
lines.push(`${prefix}${dayStr} = ${iso}`)
}
return lines.join("\n")
}
/**
* Builds a compact text calendar from all calendar-type items.
* Groups events by day relative to currentTime.
*/
function buildSchedule(items: FeedItem[], currentTime: Date): string {
const entries: CalendarEntry[] = []
for (const item of items) {
const entry = extractCalendarEntry(item)
if (entry) entries.push(entry)
}
if (entries.length === 0) return ""
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
const byDay = new Map<string, CalendarEntry[]>()
for (const entry of entries) {
const key = entry.date.toISOString().slice(0, 10)
const group = byDay.get(key)
if (group) {
group.push(entry)
} else {
byDay.set(key, [entry])
}
}
const lines: string[] = []
for (const [, dayEntries] of byDay) {
lines.push(formatDayLabel(dayEntries[0]!.startTime, currentTime))
for (const entry of dayEntries) {
if (entry.isAllDay) {
const loc = entry.location ? ` @ ${entry.location}` : ""
lines.push(` all day ${entry.title}${loc}`)
} else {
const timeRange = `${formatTime(entry.startTime)}${formatTime(entry.endTime)}`
const loc = entry.location ? ` @ ${entry.location}` : ""
lines.push(` ${timeRange} ${entry.title}${loc}`)
}
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,21 @@
You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
The user message is a JSON object with:
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
- "context": other feed items (no slots) for cross-source reasoning.
- "time": current ISO timestamp.
Your output has two fields:
- "slotFills": map of item ID → slot name → short text (or null if you can't fill it or cannot provide answer). Each item ID appears ONCE with ALL its slots in a single object.
- "syntheticItems": array of { id, type, text } for new items (briefings, nudges, insights). Only when genuinely useful and when not redundant.
Rules:
- DO NOT USE EMDASH OR DASH OR ATTEMPT TO USE SYMBOLS TO CIRCUMVENT THIS RULE.
- One sentence per slot. Two max if absolutely necessary. Be direct.
- Say "I" not "we."
- Hedge when inferring. Don't state guesses as facts.
- Use the week and schedule below to understand when events happen. Match weather data to the correct date.
- Look for connections across items.
- Don't pad — return null for slots you can't meaningfully fill, and skip synthetic items if there's nothing useful to add.
- Never fabricate information not present in the feed. If you don't have data to support a fill, return null.
- Read each slot's description carefully — it defines when to return null.

View File

@@ -0,0 +1,176 @@
import { describe, expect, test } from "bun:test"
import {
emptyEnhancementResult,
enhancementResultJsonSchema,
parseEnhancementResult,
} from "./schema.ts"
describe("parseEnhancementResult", () => {
test("parses valid result", () => {
const input = JSON.stringify({
slotFills: {
"weather-1": {
insight: "Rain after 3pm",
"cross-source": null,
},
},
syntheticItems: [
{
id: "briefing-morning",
type: "briefing",
text: "Light afternoon ahead.",
},
],
})
const result = parseEnhancementResult(input)
expect(result).not.toBeNull()
expect(result!.slotFills["weather-1"]!.insight).toBe("Rain after 3pm")
expect(result!.slotFills["weather-1"]!["cross-source"]).toBeNull()
expect(result!.syntheticItems).toHaveLength(1)
expect(result!.syntheticItems[0]!.id).toBe("briefing-morning")
expect(result!.syntheticItems[0]!.text).toBe("Light afternoon ahead.")
})
test("parses empty result", () => {
const input = JSON.stringify({
slotFills: {},
syntheticItems: [],
})
const result = parseEnhancementResult(input)
expect(result).not.toBeNull()
expect(Object.keys(result!.slotFills)).toHaveLength(0)
expect(result!.syntheticItems).toHaveLength(0)
})
test("returns null for invalid JSON", () => {
expect(parseEnhancementResult("not json")).toBeNull()
})
test("returns null for non-object", () => {
expect(parseEnhancementResult('"hello"')).toBeNull()
expect(parseEnhancementResult("42")).toBeNull()
expect(parseEnhancementResult("null")).toBeNull()
})
test("returns null when slotFills is missing", () => {
const input = JSON.stringify({ syntheticItems: [] })
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when syntheticItems is missing", () => {
const input = JSON.stringify({ slotFills: {} })
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when slotFills has non-string values", () => {
const input = JSON.stringify({
slotFills: { "item-1": { slot: 42 } },
syntheticItems: [],
})
expect(parseEnhancementResult(input)).toBeNull()
})
test("returns null when syntheticItem is missing required fields", () => {
const input = JSON.stringify({
slotFills: {},
syntheticItems: [{ id: "x" }],
})
expect(parseEnhancementResult(input)).toBeNull()
})
})
describe("emptyEnhancementResult", () => {
test("returns empty slotFills and syntheticItems", () => {
const result = emptyEnhancementResult()
expect(result.slotFills).toEqual({})
expect(result.syntheticItems).toEqual([])
})
})
describe("schema sync", () => {
const referencePayloads = [
{
name: "full payload with null slot fill",
payload: {
slotFills: {
"weather-1": { insight: "Rain after 3pm", crossSource: null },
"cal-2": { summary: "Busy morning" },
},
syntheticItems: [
{ id: "briefing-morning", type: "briefing", text: "Light day ahead." },
{ id: "nudge-umbrella", type: "nudge", text: "Bring an umbrella." },
],
},
},
{
name: "empty collections",
payload: { slotFills: {}, syntheticItems: [] },
},
{
name: "slot fills only",
payload: {
slotFills: { "item-1": { slot: "filled" } },
syntheticItems: [],
},
},
{
name: "synthetic items only",
payload: {
slotFills: {},
syntheticItems: [{ id: "insight-1", type: "insight", text: "Something." }],
},
},
]
for (const { name, payload } of referencePayloads) {
test(`arktype and JSON Schema agree on: ${name}`, () => {
// arktype accepts it
const parsed = parseEnhancementResult(JSON.stringify(payload))
expect(parsed).not.toBeNull()
// JSON Schema structure matches
const jsonSchema = enhancementResultJsonSchema
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
Object.keys(payload).sort(),
)
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
// syntheticItems item schema has the right required fields
const itemSchema = jsonSchema.properties.syntheticItems.items
expect([...itemSchema.required].sort()).toEqual(["id", "text", "type"])
// Verify each synthetic item has exactly the fields the JSON Schema expects
for (const item of payload.syntheticItems) {
expect(Object.keys(item).sort()).toEqual([...itemSchema.required].sort())
}
})
}
test("JSON Schema rejects what arktype rejects: missing required field", () => {
// Missing syntheticItems
expect(parseEnhancementResult(JSON.stringify({ slotFills: {} }))).toBeNull()
// JSON Schema also requires it
expect(enhancementResultJsonSchema.required).toContain("syntheticItems")
})
test("JSON Schema rejects what arktype rejects: wrong slot fill value type", () => {
const bad = { slotFills: { "item-1": { slot: 42 } }, syntheticItems: [] }
// arktype rejects it
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values
const slotValueTypes =
enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties.type
expect(slotValueTypes).toContain("string")
expect(slotValueTypes).toContain("null")
expect(slotValueTypes).not.toContain("number")
})
})

View File

@@ -0,0 +1,89 @@
import { type } from "arktype"
const SyntheticItem = type({
id: "string",
type: "string",
text: "string",
})
const EnhancementResult = type({
slotFills: "Record<string, Record<string, string | null>>",
syntheticItems: SyntheticItem.array(),
})
export type SyntheticItem = typeof SyntheticItem.infer
export type EnhancementResult = typeof EnhancementResult.infer
/**
* JSON Schema passed to OpenRouter's structured output.
* OpenRouter doesn't support arktype, so this is maintained separately.
*
* ⚠️ Must stay in sync with EnhancementResult above.
* If you add/remove fields, update both schemas.
*/
export const enhancementResultJsonSchema = {
type: "object",
properties: {
slotFills: {
type: "object",
description:
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
additionalProperties: {
type: "object",
additionalProperties: {
type: ["string", "null"],
},
},
},
syntheticItems: {
type: "array",
description:
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "Unique ID, e.g. 'briefing-morning'",
},
type: {
type: "string",
description: "One of: 'briefing', 'nudge', 'insight'",
},
text: {
type: "string",
description: "Display text, 1-3 sentences",
},
},
required: ["id", "type", "text"],
additionalProperties: false,
},
},
},
required: ["slotFills", "syntheticItems"],
additionalProperties: false,
} as const
/**
* Parses a JSON string into an EnhancementResult.
* Returns null if the input is malformed.
*/
export function parseEnhancementResult(json: string): EnhancementResult | null {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return null
}
const result = EnhancementResult(parsed)
if (result instanceof type.errors) {
return null
}
return result
}
export function emptyEnhancementResult(): EnhancementResult {
return { slotFills: {}, syntheticItems: [] }
}

View File

@@ -45,7 +45,7 @@ async function handleUpdateLocation(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aris.location", "update-location", {
await session.engine.executeAction("aelis.location", "update-location", {
lat: result.lat,
lng: result.lng,
accuracy: result.accuracy,

View File

@@ -0,0 +1,67 @@
import { LocationSource } from "@aelis/source-location"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
function main() {
const openrouterApiKey = process.env.OPENROUTER_API_KEY
const feedEnhancer = openrouterApiKey
? createFeedEnhancer({
client: createLlmClient({
apiKey: openrouterApiKey,
model: process.env.OPENROUTER_MODEL || undefined,
}),
})
: null
if (!feedEnhancer) {
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
}
const sessionManager = new UserSessionManager({
providers: [
() => new LocationSource(),
new WeatherSourceProvider({
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!,
teamId: process.env.WEATHERKIT_TEAM_ID!,
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
}),
],
feedEnhancer,
})
const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" }))
const isDev = process.env.NODE_ENV !== "production"
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
if (!isDev) {
registerAuthHandlers(app)
}
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware,
})
registerLocationHttpHandlers(app, { sessionManager })
return app
}
const app = main()
export default {
port: 3000,
fetch: app.fetch,
}

View File

@@ -1,4 +1,4 @@
import type { FeedSource } from "@aris/core"
import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource

View File

@@ -1,6 +1,6 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aris/source-location"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
@@ -12,7 +12,7 @@ const mockWeatherClient: WeatherKitClient = {
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
@@ -21,7 +21,7 @@ describe("UserSessionManager", () => {
})
test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
@@ -30,7 +30,7 @@ describe("UserSessionManager", () => {
})
test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
@@ -39,19 +39,19 @@ describe("UserSessionManager", () => {
})
test("each user gets independent source instances", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aris.location")
const source2 = session2.getSource<LocationSource>("aris.location")
const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location")
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
manager.remove("user-1")
@@ -61,13 +61,13 @@ describe("UserSessionManager", () => {
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("accepts function providers", async () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
@@ -77,25 +77,29 @@ describe("UserSessionManager", () => {
test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager([() => new LocationSource(), provider])
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.weather")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager([() => new LocationSource(), provider])
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aris.location")).toBeDefined()
expect(session.getSource("aris.weather")).toBeDefined()
expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
@@ -107,28 +111,28 @@ describe("UserSessionManager", () => {
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aris.location", "update-location", {
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
const source = session.getSource<LocationSource>("aris.location")
const source = session.getSource<LocationSource>("aelis.location")
expect(source?.lastLocation?.lat).toBe(51.5074)
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aris.location", "update-location", {
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
@@ -142,7 +146,7 @@ describe("UserSessionManager", () => {
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager([() => new LocationSource()])
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
@@ -152,7 +156,7 @@ describe("UserSessionManager", () => {
// Create new session and push location — old callback should not fire
const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aris.location", "update-location", {
await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,

View File

@@ -1,13 +1,21 @@
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
providers: FeedSourceProviderInput[]
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null
constructor(providers: FeedSourceProviderInput[]) {
this.providers = providers
constructor(config: UserSessionManagerConfig) {
this.providers = config.providers
this.feedEnhancer = config.feedEnhancer ?? null
}
getOrCreate(userId: string): UserSession {
@@ -16,7 +24,7 @@ export class UserSessionManager {
const sources = this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
)
session = new UserSession(sources)
session = new UserSession(sources, this.feedEnhancer)
this.sessions.set(userId, session)
}
return session

View File

@@ -0,0 +1,216 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("getSource returns registered source", () => {
const location = new LocationSource()
const session = new UserSession([location])
const result = session.getSource<LocationSource>("aelis.location")
expect(result).toBe(location)
})
test("getSource returns undefined for unknown source", () => {
const session = new UserSession([createStubSource("test")])
expect(session.getSource("unknown")).toBeUndefined()
})
test("destroy stops engine and clears sources", () => {
const session = new UserSession([createStubSource("test")])
session.destroy()
expect(session.getSource("test")).toBeUndefined()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession([location])
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5,
lng: -0.1,
accuracy: 10,
timestamp: new Date(),
})
expect(location.lastLocation).toBeDefined()
expect(location.lastLocation!.lat).toBe(51.5)
})
})
describe("UserSession.feed", () => {
test("returns feed items without enhancer", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const session = new UserSession([createStubSource("test", items)])
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
})
test("returns enhanced items when enhancer is provided", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async (feedItems: FeedItem[]) =>
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.data.enhanced).toBe(true)
})
test("caches enhanced items on subsequent calls", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
let enhancerCallCount = 0
const enhancer = async (feedItems: FeedItem[]) => {
enhancerCallCount++
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result1 = await session.feed()
expect(result1.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
const result2 = await session.feed()
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
})
test("re-enhances after engine refresh with new data", async () => {
let currentItems: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { version: 1 },
},
]
const source = createStubSource("test", currentItems)
// Make fetchItems dynamic so refresh returns new data
source.fetchItems = async () => currentItems
const enhancedVersions: number[] = []
const enhancer = async (feedItems: FeedItem[]) => {
const version = feedItems[0]!.data.version as number
enhancedVersions.push(version)
return feedItems.map((item) => ({
...item,
data: { ...item.data, enhanced: true },
}))
}
const session = new UserSession([source], enhancer)
// First feed triggers refresh + enhancement
const result1 = await session.feed()
expect(result1.items[0]!.data.version).toBe(1)
expect(result1.items[0]!.data.enhanced).toBe(true)
// Update source data and trigger engine refresh
currentItems = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-02T00:00:00.000Z"),
data: { version: 2 },
},
]
await session.engine.refresh()
// Wait for subscriber-triggered background enhancement
await new Promise((resolve) => setTimeout(resolve, 10))
// feed() should now serve re-enhanced items with version 2
const result2 = await session.feed()
expect(result2.items[0]!.data.version).toBe(2)
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancedVersions).toEqual([1, 2])
})
test("falls back to unenhanced items when enhancer throws", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async () => {
throw new Error("enhancement exploded")
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
expect(result.items[0]!.data.value).toBe(42)
})
})

View File

@@ -0,0 +1,104 @@
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
export class UserSession {
readonly engine: FeedEngine
private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null
/** The FeedResult that enhancedItems was derived from. */
private enhancedSource: FeedResult | null = null
private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
for (const source of sources) {
this.sources.set(source.id, source)
this.engine.register(source)
}
if (this.enhancer) {
this.unsubscribe = this.engine.subscribe((result) => {
this.invalidateEnhancement()
this.runEnhancement(result)
})
}
this.engine.start()
}
/**
* Returns the current feed, refreshing if the engine cache expired.
* Enhancement runs eagerly on engine updates; this method awaits
* any in-flight enhancement or triggers one if needed.
*/
async feed(): Promise<FeedResult> {
const cached = this.engine.lastFeed()
const result = cached ?? (await this.engine.refresh())
if (!this.enhancer) {
return result
}
// Wait for any in-flight background enhancement to finish
if (this.enhancingPromise) {
await this.enhancingPromise
}
// Serve cached enhancement only if it matches the current engine result
if (this.enhancedItems && this.enhancedSource === result) {
return { ...result, items: this.enhancedItems }
}
// Stale or missing — re-enhance
await this.runEnhancement(result)
if (this.enhancedItems) {
return { ...result, items: this.enhancedItems }
}
return result
}
getSource<T extends FeedSource>(sourceId: string): T | undefined {
return this.sources.get(sourceId) as T | undefined
}
destroy(): void {
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()
this.sources.clear()
this.invalidateEnhancement()
this.enhancingPromise = null
}
private invalidateEnhancement(): void {
this.enhancedItems = null
this.enhancedSource = null
}
private runEnhancement(result: FeedResult): Promise<void> {
const promise = this.enhance(result)
this.enhancingPromise = promise
promise.finally(() => {
if (this.enhancingPromise === promise) {
this.enhancingPromise = null
}
})
return promise
}
private async enhance(result: FeedResult): Promise<void> {
try {
this.enhancedItems = await this.enhancer!(result.items)
this.enhancedSource = result
} catch (err) {
console.error("[enhancement] Unexpected error:", err)
this.invalidateEnhancement()
}
}
}

View File

@@ -1,4 +1,4 @@
import { TflSource, type ITflApi } from "@aris/source-tfl"
import { TflSource, type ITflApi } from "@aelis/source-tfl"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"

View File

@@ -1,4 +1,4 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"

View File

@@ -1,11 +1,11 @@
{
"expo": {
"name": "Aris",
"slug": "aris-client",
"name": "Aelis",
"slug": "aelis-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "aris",
"scheme": "aelis",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
@@ -15,7 +15,7 @@
},
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "sh.nym.aris"
"bundleIdentifier": "sh.nym.aelis"
},
"android": {
"adaptiveIcon": {
@@ -26,7 +26,7 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.aris"
"package": "sh.nym.aelis"
},
"web": {
"output": "static",

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,5 +1,5 @@
{
"name": "aris-client",
"name": "aelis-client",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
@@ -18,6 +18,7 @@
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -45,7 +46,8 @@
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0"
"twrnc": "^4.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "~19.1.0",

View File

@@ -0,0 +1,45 @@
import "react-native-reanimated"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import { useColorScheme } from "react-native"
import tw, { useDeviceContext } from "twrnc"
export default function RootLayout() {
useDeviceContext(tw)
const colorScheme = useColorScheme()
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
return (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: headerBg },
}}
>
<Stack.Screen
name="components/index"
options={{
headerShown: true,
title: "Components",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="components/[name]"
options={{
headerShown: true,
title: "",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
</Stack>
<StatusBar style="auto" />
</>
)
}

View File

@@ -0,0 +1,48 @@
import { useLocalSearchParams, useNavigation } from "expo-router"
import { useEffect } from "react"
import { ScrollView, View } from "react-native"
import tw from "twrnc"
import { buttonShowcase } from "@/components/ui/button.showcase"
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
import { type Showcase } from "@/components/showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const showcases: Record<string, Showcase> = {
button: buttonShowcase,
"feed-card": feedCardShowcase,
"serif-text": serifTextShowcase,
"sans-serif-text": sansSerifTextShowcase,
"monospace-text": monospaceTextShowcase,
}
export default function ComponentDetailScreen() {
const { name } = useLocalSearchParams<{ name: string }>()
const navigation = useNavigation()
const showcase = showcases[name]
useEffect(() => {
if (showcase) {
navigation.setOptions({ title: showcase.title })
}
}, [navigation, showcase])
if (!showcase) {
return (
<View style={tw`bg-stone-100 dark:bg-stone-900 flex-1 items-center justify-center`}>
<SansSerifText>Component not found</SansSerifText>
</View>
)
}
const ShowcaseComponent = showcase.component
return (
<ScrollView style={tw`bg-stone-100 dark:bg-stone-900 flex-1`} contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}>
<ShowcaseComponent />
</ScrollView>
)
}

View File

@@ -0,0 +1,37 @@
import { Link } from "expo-router"
import { FlatList, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const components = [
{ name: "button", label: "Button" },
{ name: "feed-card", label: "FeedCard" },
{ name: "serif-text", label: "SerifText" },
{ name: "sans-serif-text", label: "SansSerifText" },
{ name: "monospace-text", label: "MonospaceText" },
] as const
export default function ComponentsScreen() {
return (
<View style={tw`flex-1`}>
<View style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}>
<FlatList
data={components}
keyExtractor={(item) => item.name}
scrollEnabled={false}
ItemSeparatorComponent={() => (
<View style={tw`border-b border-stone-200 dark:border-stone-800`} />
)}
renderItem={({ item }) => (
<Link href={`/components/${item.name}`} asChild>
<Pressable style={tw`px-4 py-3`}>
<SansSerifText style={tw`text-base`}>{item.label}</SansSerifText>
</Pressable>
</Link>
)}
/>
</View>
</View>
)
}

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

@@ -0,0 +1,18 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./ui/sans-serif-text"
export type Showcase = {
title: string
component: React.ComponentType
}
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={tw`gap-3`}>
<SansSerifText style={tw`text-sm text-stone-500 dark:text-stone-400`}>{title}</SansSerifText>
{children}
</View>
)
}

View File

@@ -0,0 +1,42 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
function ButtonShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Button.Icon name="plus" />}
/>
</Section>
<Section title="Trailing icon">
<Button
style={tw`self-start`}
label="Next"
trailingIcon={<Button.Icon name="arrow-right" />}
/>
</Section>
<Section title="Both icons">
<Button
style={tw`self-start`}
label="Download"
leadingIcon={<Button.Icon name="download" />}
trailingIcon={<Button.Icon name="chevron-down" />}
/>
</Section>
</View>
)
}
export const buttonShowcase: Showcase = {
title: "Button",
component: ButtonShowcase,
}

View File

@@ -0,0 +1,43 @@
import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
type ButtonIconProps = {
name: FeatherIconName
}
function ButtonIcon({ name }: ButtonIconProps) {
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
}
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
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>
return (
<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>
)
}
Button.Icon = ButtonIcon

View File

@@ -0,0 +1,32 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<FeedCard style={tw`p-4`}>
<SansSerifText>Card content goes here</SansSerifText>
</FeedCard>
</Section>
<Section title="With mixed content">
<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`} label="Action" />
</FeedCard>
</Section>
</View>
)
}
export const feedCardShowcase: Showcase = {
title: "FeedCard",
component: FeedCardShowcase,
}

Some files were not shown because too many files have changed in this diff Show More