Compare commits

..

14 Commits

Author SHA1 Message Date
243e84622a docs: agent vision and enhancement architecture
Rewrite ai-agent-ideas.md with focus on proactive,
personable assistant behaviors. Add slot-based LLM
enhancement system to architecture-draft.md.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-26 22:44:04 +00:00
6ae0ad1d40 Merge pull request #36 from kennethnym/feat/feed-endpoint
feat: add GET /api/feed endpoint
2026-02-24 22:33:49 +00:00
941acb826c feat: add GET /api/feed endpoint
Expose the user's current feed via GET /api/feed. Returns
cached feed from engine.lastFeed(), falling back to
engine.refresh() when no cache exists.

Auth middleware is injected as a dependency to allow test
substitution via mockAuthSessionMiddleware.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 22:30:13 +00:00
3d492a5d56 Merge pull request #35 from kennethnym/feat/feed-engine-cache
feat: add caching to FeedEngine
2026-02-24 01:21:20 +00:00
08dd437952 feat: add caching to FeedEngine
Add lastFeed() method that returns cached FeedResult within
a configurable TTL (default 5 min). refresh() always fetches
fresh data and updates the cache. Periodic auto-refresh via
recursive setTimeout when engine is started. Reactive updates
reset the timer to avoid redundant fetches.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 01:13:41 +00:00
2fc20759dd Merge pull request #34 from kennethnym/refactor/remove-trpc
refactor: remove tRPC, use plain Hono routes
2026-02-22 21:10:16 +00:00
769e2d4eb0 Merge pull request #33 from kennethnym/feat/source-serif-4-font
Add Source Serif 4 font to aris-client
2026-02-22 19:45:19 +00:00
5e9094710d fix(client): match Source Serif 4 family name
Use 'Source Serif 4' (with spaces) as the Android fontFamily
to match the iOS font metadata, avoiding Platform.select.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:44:27 +00:00
5556f3fbf9 fix(client): specify font weight and style
Use the object syntax with fontFamily, weight, and style
for Android. iOS uses flat paths and reads metadata from
the font files directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:09:55 +00:00
0176979925 feat(client): embed Inter font assets
Copy Inter font files to assets/fonts/ and add them
to the expo-font config plugin alongside Source Serif 4.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:05:01 +00:00
971aba0932 fix(client): use local font assets for EAS
Bun symlinks in node_modules don't resolve on EAS builds.
Copy font files to assets/fonts/ and reference them directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:03:23 +00:00
68e319e4b8 feat(client): add Source Serif 4 font
Install @expo-google-fonts/source-serif-4 and configure
expo-font plugin in app.json to embed all weights.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 18:42:50 +00:00
c042af88f3 Merge pull request #32 from kennethnym/dev/ios-simulator-build
dev(client): add ios simulator build
2026-02-22 17:54:01 +00:00
0608f2ac61 dev(client): add ios simulator build 2026-02-22 17:53:13 +00:00
47 changed files with 2095 additions and 207 deletions

View File

@@ -1,4 +1,4 @@
import type { Context, Next } from "hono"
import type { Context, MiddlewareHandler, Next } from "hono"
import type { AuthSession, AuthUser } from "./session.ts"
@@ -9,6 +9,10 @@ export interface SessionVariables {
session: AuthSession | null
}
export type AuthSessionEnv = { Variables: SessionVariables }
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
declare module "hono" {
interface ContextVariableMap extends SessionVariables {}
}
@@ -55,3 +59,18 @@ export async function getSessionFromHeaders(
const session = await auth.api.getSession({ headers })
return session
}
/**
* Test-only 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 {
return async (c: Context, next: Next): Promise<Response | void> => {
if (!userId) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", { id: userId } as AuthUser)
c.set("session", { id: "mock-session" } as AuthSession)
await next()
}
}

View File

@@ -0,0 +1,140 @@
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/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[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<Partial<Context> | null> {
return null
},
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([])
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",
type: "test",
priority: 0.8,
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager([() => 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",
type: "test",
priority: 0.5,
timestamp: new Date("2025-06-01T12:00:00.000Z"),
data: { fresh: true },
},
]
const manager = new UserSessionManager([() => 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([() => 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")
})
})

View File

@@ -0,0 +1,41 @@
import type { Context, Hono } from "hono"
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)
}
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}

View File

@@ -2,6 +2,8 @@ import { LocationSource } from "@aris/source-location"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { requireSession } from "./auth/session-middleware.ts"
import { registerFeedHttpHandlers } from "./feed/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
@@ -24,6 +26,7 @@ function main() {
app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app)
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
registerLocationHttpHandlers(app, { sessionManager })
return app

View File

@@ -46,7 +46,97 @@
}
}
],
"expo-font"
[
"expo-font",
{
"android": {
"fonts": [
{
"fontFamily": "Inter",
"fontDefinitions": [
{ "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_Italic.ttf", "weight": 200, "style": "italic" },
{ "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_Italic.ttf", "weight": 400, "style": "italic" },
{ "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_Italic.ttf", "weight": 600, "style": "italic" },
{ "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_Italic.ttf", "weight": 800, "style": "italic" },
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
]
},
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{ "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_Italic.ttf", "weight": 300, "style": "italic" },
{ "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_Italic.ttf", "weight": 500, "style": "italic" },
{ "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_Italic.ttf", "weight": 700, "style": "italic" },
{ "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_Italic.ttf", "weight": 900, "style": "italic" }
]
}
]
},
"ios": {
"fonts": [
"./assets/fonts/Inter_100Thin.ttf",
"./assets/fonts/Inter_100Thin_Italic.ttf",
"./assets/fonts/Inter_200ExtraLight.ttf",
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"./assets/fonts/Inter_300Light.ttf",
"./assets/fonts/Inter_300Light_Italic.ttf",
"./assets/fonts/Inter_400Regular.ttf",
"./assets/fonts/Inter_400Regular_Italic.ttf",
"./assets/fonts/Inter_500Medium.ttf",
"./assets/fonts/Inter_500Medium_Italic.ttf",
"./assets/fonts/Inter_600SemiBold.ttf",
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
"./assets/fonts/Inter_700Bold.ttf",
"./assets/fonts/Inter_700Bold_Italic.ttf",
"./assets/fonts/Inter_800ExtraBold.ttf",
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"./assets/fonts/Inter_900Black.ttf",
"./assets/fonts/Inter_900Black_Italic.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"./assets/fonts/SourceSerif4_300Light.ttf",
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"./assets/fonts/SourceSerif4_400Regular.ttf",
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"./assets/fonts/SourceSerif4_500Medium.ttf",
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"./assets/fonts/SourceSerif4_700Bold.ttf",
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"./assets/fonts/SourceSerif4_900Black.ttf",
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
]
}
}
]
],
"experiments": {
"typedRoutes": true,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,12 @@
"developmentClient": true,
"distribution": "internal"
},
"development-simulator": {
"extends": "development",
"ios": {
"simulator": "true"
}
},
"preview": {
"distribution": "internal"
},

View File

@@ -11,10 +11,12 @@
"web": "expo start --web",
"lint": "expo lint",
"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": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",

View File

@@ -35,6 +35,7 @@
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -383,6 +384,8 @@
"@expo-google-fonts/inter": ["@expo-google-fonts/inter@0.4.2", "", {}, "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ=="],
"@expo-google-fonts/source-serif-4": ["@expo-google-fonts/source-serif-4@0.4.1", "", {}, "sha512-Ej4UXDjW1kwYPHG8YLq6fK1bqnJGb3K35J3S5atSL0ScKFAFLKvndxoTWeCls7mybtlS9x99hzwDeXCBkiI3rA=="],
"@expo/apple-utils": ["@expo/apple-utils@2.1.13", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg=="],
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],

File diff suppressed because it is too large Load Diff

View File

@@ -104,7 +104,289 @@ This approach:
Reference: https://github.com/vercel-labs/json-render
## Feed Items with UI and Slots
> Note: the codebase has evolved since the sections above. The engine now uses a dependency graph with topological ordering (`FeedEngine`, `FeedSource`), not the parallel reconciler described above. The `priority` field is being replaced by post-processing (see the ideas doc). This section describes the UI and enhancement architecture going forward.
Feed items carry an optional `ui` field containing a json-render tree, and an optional `slots` field for LLM-fillable content.
```typescript
interface FeedItem<TType, TData> {
id: string
type: TType
timestamp: Date
data: TData
ui?: JsonRenderNode
slots?: Record<string, Slot>
}
interface Slot {
/** Tells the LLM what this slot wants — the source writes this */
description: string
/** LLM-filled text content, null until enhanced */
content: string | null
}
```
### How it works
The source produces the item with a UI tree and empty slots:
```typescript
// Weather source produces:
{
id: "weather-current-123",
type: "weather-current",
data: { temperature: 18, condition: "cloudy" },
ui: {
component: "VStack",
children: [
{ component: "WeatherHeader", props: { temp: 18, condition: "cloudy" } },
{ component: "Slot", props: { name: "insight" } },
{ component: "HourlyChart", props: { hours: [...] } },
{ component: "Slot", props: { name: "cross-source" } },
]
},
slots: {
"insight": {
description: "A short contextual insight about the current weather and how it affects the user's day",
content: null
},
"cross-source": {
description: "Connection between weather and the user's calendar events or plans",
content: null
}
}
}
```
The LLM enhancement harness fills `content`:
```typescript
slots: {
"insight": {
description: "...",
content: "Rain after 3pm — grab a jacket before your walk"
},
"cross-source": {
description: "...",
content: "Should be dry by 7pm for your dinner at The Ivy"
}
}
```
The client renders the `ui` tree. When it hits a `Slot` node, it looks up `slots[name].content`. If non-null, render the text. If null, render nothing.
### Separation of concerns
- **Sources** own the UI layout and declare what slots exist with descriptions.
- **The LLM** fills slot content. It doesn't know about layout or positioning.
- **The client** renders the UI tree and resolves slots to their content.
Sources define the prompt for each slot via the `description` field. The harness doesn't need to know what slots any source type has — it reads them dynamically from the items.
Each source defines its own slots. The harness handles them automatically — no central registry needed.
## Enhancement Harness
The LLM enhancement harness fills slots and produces synthetic feed items. It runs reactively — triggered by context changes, not by a timer.
### Execution model
```
FeedEngine.refresh()
→ sources produce items with ui + empty slots
Fast path (rule-based post-processors, <10ms)
→ group, dedup, affinity, time-adjust
→ merge LAST cached slot fills + synthetic items
→ return feed to UI immediately
Background: has context changed since last LLM run?
(hash of: item IDs + data + slot descriptions + user memory)
No → done, cache is still valid
Yes → run LLM harness async
→ fill slots + generate synthetic items
→ cache result
→ push updated feed to UI via WebSocket
```
The user never waits for the LLM. They see the feed instantly with the previous enhancement applied. If the LLM produces new slot content or synthetic items, the feed updates in place.
### LLM input
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
```typescript
function buildHarnessInput(
items: FeedItem[],
context: AgentContext,
): HarnessInput {
const itemsWithSlots = items
.filter(item => item.slots && Object.keys(item.slots).length > 0)
.map(item => ({
id: item.id,
type: item.type,
data: item.data,
slots: Object.fromEntries(
Object.entries(item.slots!).map(
([name, slot]) => [name, slot.description]
)
),
}))
return {
items: itemsWithSlots,
userMemory: context.preferences,
currentTime: new Date().toISOString(),
}
}
```
The LLM sees:
```json
{
"items": [
{
"id": "weather-current-123",
"type": "weather-current",
"data": { "temperature": 18, "condition": "cloudy" },
"slots": {
"insight": "A short contextual insight about the current weather and how it affects the user's day",
"cross-source": "Connection between weather and the user's calendar events or plans"
}
},
{
"id": "calendar-event-456",
"type": "calendar-event",
"data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
"slots": {
"context": "Background on this event, attendees, or previous meetings with these people",
"logistics": "Travel time, parking, directions to the venue",
"weather": "Weather conditions relevant to this event's time and location"
}
}
],
"userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
"currentTime": "2025-02-26T14:30:00Z"
}
```
### LLM output
A flat map of item ID → slot name → text content. Slots left null are unfilled.
```json
{
"slotFills": {
"weather-current-123": {
"insight": "Rain after 3pm — grab a jacket before your walk",
"cross-source": "Should be dry by 7pm for your dinner at The Ivy"
},
"calendar-event-456": {
"context": null,
"logistics": "20-minute walk from home — leave by 18:40",
"weather": "Rain clears by evening, you'll be fine"
}
},
"syntheticItems": [
{
"id": "briefing-morning",
"type": "briefing",
"data": {},
"ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
}
],
"suppress": [],
"rankingHints": {}
}
```
### Enhancement manager
One per user, living in the `FeedEngineManager` on the backend:
```typescript
class EnhancementManager {
private cache: EnhancementResult | null = null
private lastInputHash: string | null = null
private running = false
async enhance(
items: FeedItem[],
context: AgentContext,
): Promise<EnhancementResult> {
const hash = computeHash(items, context)
if (hash === this.lastInputHash && this.cache) {
return this.cache
}
if (this.running) {
return this.cache ?? emptyResult()
}
this.running = true
this.runHarness(items, context)
.then(result => {
this.cache = result
this.lastInputHash = hash
this.notifySubscribers(result)
})
.finally(() => { this.running = false })
return this.cache ?? emptyResult()
}
}
interface EnhancementResult {
slotFills: Record<string, Record<string, string | null>>
syntheticItems: FeedItem[]
suppress: string[]
rankingHints: Record<string, number>
}
```
### Merging
After the harness runs, the engine merges slot fills into items:
```typescript
function mergeEnhancement(
items: FeedItem[],
result: EnhancementResult,
): FeedItem[] {
return items.map(item => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item
const mergedSlots = { ...item.slots }
for (const [name, content] of Object.entries(fills)) {
if (name in mergedSlots && content !== null) {
mergedSlots[name] = { ...mergedSlots[name], content }
}
}
return { ...item, slots: mergedSlots }
})
}
```
### Cost control
- **Hash-based cache gate.** Most refreshes reuse the cached result.
- **Debounce.** Rapid context changes (location updates) settle before triggering a run.
- **Skip inactive users.** Don't run if the user hasn't opened the app in 2+ hours.
- **Exclude slotless items.** Only items with slots are sent to the LLM.
- **Text-only output.** Slots produce strings, not UI trees — fewer output tokens, less variance.
## Open Questions
- Exact schema format for UI registry
- How third parties authenticate/register their sources and UI schemas
- JsonRenderNode type definition and component vocabulary
- How synthetic items define their UI (full json-render tree vs. registered schema)
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)

View File

@@ -638,4 +638,290 @@ describe("FeedEngine", () => {
)
})
})
describe("lastFeed", () => {
test("returns null before any refresh", () => {
const engine = new FeedEngine()
expect(engine.lastFeed()).toBeNull()
})
test("returns cached result after refresh", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const refreshResult = await engine.refresh()
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toEqual(refreshResult.items)
expect(cached!.context).toEqual(refreshResult.context)
})
test("returns null after TTL expires", async () => {
const engine = new FeedEngine({ cacheTtlMs: 50 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 60))
expect(engine.lastFeed()).toBeNull()
})
test("defaults to 5 minute TTL", async () => {
const engine = new FeedEngine()
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
// Should still be cached immediately
expect(engine.lastFeed()).not.toBeNull()
})
test("refresh always fetches from sources", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine().register(source)
await engine.refresh()
await engine.refresh()
await engine.refresh()
expect(fetchCount).toBe(3)
})
test("reactive context update refreshes cache", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
engine.start()
// Simulate location update which triggers reactive refresh
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
// Wait for async reactive refresh to complete
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items.length).toBeGreaterThan(0)
engine.stop()
})
test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: (() => void) | null = null
const source: FeedSource = {
id: "reactive-items",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [
{
id: "item-1",
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
onItemsUpdate(callback) {
itemUpdateCallback = callback
return () => {
itemUpdateCallback = null
}
},
}
const engine = new FeedEngine().register(source)
engine.start()
// Trigger item update
itemUpdateCallback!()
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toHaveLength(1)
engine.stop()
})
test("TTL resets after reactive update", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
engine.start()
// Initial reactive update
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(engine.lastFeed()).not.toBeNull()
// 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
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
})
test("cacheTtlMs is configurable", async () => {
const engine = new FeedEngine({ cacheTtlMs: 30 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 40))
expect(engine.lastFeed()).toBeNull()
})
test("auto-refreshes on TTL interval after start", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
async fetchItems() {
return [
{
id: `item-${fetchCount}`,
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
// 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()
engine.stop()
})
test("stop cancels periodic refresh", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
engine.stop()
const countAfterStop = fetchCount
// Wait past TTL
await new Promise((resolve) => setTimeout(resolve, 80))
// No additional fetches after stop
expect(fetchCount).toBe(countAfterStop)
})
test("reactive update resets periodic refresh timer", async () => {
let fetchCount = 0
const location = createLocationSource()
const countingWeather: FeedSource<WeatherFeedItem> = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
fetchCount++
const loc = contextValue(ctx, LocationKey)
if (!loc) return null
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
async fetchItems(ctx) {
const weather = contextValue(ctx, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temperature: weather.temperature, condition: weather.condition },
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 100 })
.register(location)
.register(countingWeather)
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 new Promise((resolve) => setTimeout(resolve, 20))
// Reactive update triggered a fetch
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
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()
})
})
})

View File

@@ -16,6 +16,14 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
export interface FeedEngineConfig {
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
cacheTtlMs?: number
}
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private cleanups: Array<() => void> = []
private started = false
private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null
private cachedAt: number | null = null
private refreshTimer: ReturnType<typeof setTimeout> | null = null
constructor(config?: FeedEngineConfig) {
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
}
/**
* Returns the cached FeedResult if available and not expired.
* Returns null if no refresh has completed or the cache TTL has elapsed.
*/
lastFeed(): FeedResult<TItems> | null {
if (this.cachedResult === null || this.cachedAt === null) {
return null
}
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
return null
}
return this.cachedResult
}
/**
* Registers a FeedSource. Invalidates the cached graph.
*/
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context
return { context, items: items as TItems[], errors }
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
this.updateCache(result)
return result
}
/**
@@ -138,7 +172,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
/**
* Starts reactive subscriptions on all sources.
* Starts reactive subscriptions on all sources and begins periodic refresh.
* Sources with onContextUpdate will trigger re-computation of dependents.
*/
start(): void {
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.cleanups.push(cleanup)
}
}
this.scheduleNextRefresh()
}
/**
* Stops all reactive subscriptions.
* Stops all reactive subscriptions and the periodic refresh timer.
*/
stop(): void {
this.started = false
this.cancelScheduledRefresh()
for (const cleanup of this.cleanups) {
cleanup()
}
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items.sort((a, b) => b.priority - a.priority)
this.notifySubscribers({
const result: FeedResult<TItems> = {
context: this.context,
items: items as TItems[],
errors,
})
}
this.updateCache(result)
this.notifySubscribers(result)
}
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
@@ -307,11 +347,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
}
private updateCache(result: FeedResult<TItems>): void {
this.cachedResult = result
this.cachedAt = Date.now()
if (this.started) {
this.scheduleNextRefresh()
}
}
private scheduleNextRefresh(): void {
this.cancelScheduledRefresh()
this.refreshTimer = setTimeout(() => {
this.refresh()
.then((result) => {
this.notifySubscribers(result)
})
.catch(() => {
// Periodic refresh errors are non-fatal; schedule next attempt
if (this.started) {
this.scheduleNextRefresh()
}
})
}, this.cacheTtlMs)
}
private cancelScheduledRefresh(): void {
if (this.refreshTimer !== null) {
clearTimeout(this.refreshTimer)
this.refreshTimer = null
}
}
private scheduleRefresh(): void {
// Simple immediate refresh for now - could add debouncing later
this.refresh().then((result) => {
this.refresh()
.then((result) => {
this.notifySubscribers(result)
})
.catch(() => {
// Reactive refresh errors are non-fatal
})
}
private notifySubscribers(result: FeedResult<TItems>): void {

View File

@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
export type { FeedSource } from "./feed-source"
// Feed Engine
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"
// =============================================================================