mirror of
https://github.com/kennethnym/aris.git
synced 2026-04-25 19:21:17 +01:00
fix: add .ona and drizzle to oxfmt ignore (#119)
oxfmt was reformatting generated drizzle migration snapshots and crashing on .ona/review/comments.json. Also runs the formatter across the full codebase. Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -41,7 +41,7 @@ Sources → Source Graph → FeedEngine
|
||||
|
||||
### One harness, not many agents
|
||||
|
||||
The "agents" in this doc describe *behaviors*, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters.
|
||||
The "agents" in this doc describe _behaviors_, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters.
|
||||
|
||||
AELIS works the same way. One LLM harness receives all feed items, all context, all user memory, and all available tools. It returns a single `FeedEnhancement`. Every behavior (preparation, follow-up, anomaly detection, tone adjustment, cross-source reasoning) is an instruction in the system prompt, not a separate agent.
|
||||
|
||||
@@ -50,20 +50,21 @@ The advantage: the LLM sees everything at once. It doesn't need agent-to-agent c
|
||||
The only separate LLM call is the **Query Agent** — because it's user-initiated and synchronous. But it uses the same system prompt and context. It's the same "person," just responding to a question instead of proactively enhancing the feed.
|
||||
|
||||
Everything else is either:
|
||||
|
||||
- **Rule-based post-processors** — pure functions, no LLM, run on every refresh
|
||||
- **The single LLM harness** — runs periodically, produces cached `FeedEnhancement`
|
||||
- **Background jobs** — daily summary compression, weekly pattern discovery
|
||||
|
||||
### Component categories
|
||||
|
||||
| Component | What it is | Examples |
|
||||
|---|---|---|
|
||||
| **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness |
|
||||
| **Rule-based post-processors** | Pure functions that rerank/filter/group | TimeOfDay, CalendarGrouping, Deduplication, UserAffinity |
|
||||
| **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs |
|
||||
| **Query interface** | Synchronous LLM call, user-initiated | Conversational Q&A, web search, delegation, actions |
|
||||
| **Background jobs** | Periodic data processing | Daily summary compression, weekly pattern discovery |
|
||||
| **Persistence** | Stored state that feeds into everything | Memory store, affinity model, conversation history, feed snapshots |
|
||||
| Component | What it is | Examples |
|
||||
| ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------- |
|
||||
| **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness |
|
||||
| **Rule-based post-processors** | Pure functions that rerank/filter/group | TimeOfDay, CalendarGrouping, Deduplication, UserAffinity |
|
||||
| **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs |
|
||||
| **Query interface** | Synchronous LLM call, user-initiated | Conversational Q&A, web search, delegation, actions |
|
||||
| **Background jobs** | Periodic data processing | Daily summary compression, weekly pattern discovery |
|
||||
| **Persistence** | Stored state that feeds into everything | Memory store, affinity model, conversation history, feed snapshots |
|
||||
|
||||
### AgentContext
|
||||
|
||||
@@ -71,32 +72,32 @@ The LLM harness and post-processors need a unified view of the user's world: cur
|
||||
|
||||
`AgentContext` is **not** on the engine. The engine's job is source orchestration — running sources in dependency order, accumulating context, collecting items. It shouldn't know about user preferences, conversation history, or feed snapshots. Those are separate concerns.
|
||||
|
||||
`AgentContext` is a separate object that *reads from* the engine and composes its output with other data stores:
|
||||
`AgentContext` is a separate object that _reads from_ the engine and composes its output with other data stores:
|
||||
|
||||
```typescript
|
||||
interface AgentContext {
|
||||
/** Current accumulated context from all sources */
|
||||
context: Context
|
||||
/** Current accumulated context from all sources */
|
||||
context: Context
|
||||
|
||||
/** Recent feed items (last N refreshes or time window) */
|
||||
recentItems: FeedItem[]
|
||||
/** Recent feed items (last N refreshes or time window) */
|
||||
recentItems: FeedItem[]
|
||||
|
||||
/** Query items from a specific source */
|
||||
itemsFrom(sourceId: string): FeedItem[]
|
||||
/** Query items from a specific source */
|
||||
itemsFrom(sourceId: string): FeedItem[]
|
||||
|
||||
/** User preference and memory store */
|
||||
preferences: UserPreferences
|
||||
/** User preference and memory store */
|
||||
preferences: UserPreferences
|
||||
|
||||
/** Conversation history */
|
||||
conversationHistory: ConversationEntry[]
|
||||
/** Conversation history */
|
||||
conversationHistory: ConversationEntry[]
|
||||
}
|
||||
|
||||
// Constructed by composing the engine with persistence layers
|
||||
const agentContext = new AgentContext({
|
||||
engine, // reads current context + items
|
||||
memoryStore, // reads/writes user preferences, discovered patterns
|
||||
snapshotStore, // reads feed history for pattern discovery
|
||||
conversationStore, // reads conversation history
|
||||
engine, // reads current context + items
|
||||
memoryStore, // reads/writes user preferences, discovered patterns
|
||||
snapshotStore, // reads feed history for pattern discovery
|
||||
conversationStore, // reads conversation history
|
||||
})
|
||||
```
|
||||
|
||||
@@ -135,20 +136,20 @@ The enhancement output:
|
||||
|
||||
```typescript
|
||||
interface FeedEnhancement {
|
||||
/** New items to inject (briefings, nudges, suggestions) */
|
||||
syntheticItems: FeedItem[]
|
||||
/** New items to inject (briefings, nudges, suggestions) */
|
||||
syntheticItems: FeedItem[]
|
||||
|
||||
/** Annotations attached to existing items, keyed by item ID */
|
||||
annotations: Record<string, string>
|
||||
/** Annotations attached to existing items, keyed by item ID */
|
||||
annotations: Record<string, string>
|
||||
|
||||
/** Items to group together with a summary card */
|
||||
groups: Array<{ itemIds: string[], summary: string }>
|
||||
/** Items to group together with a summary card */
|
||||
groups: Array<{ itemIds: string[]; summary: string }>
|
||||
|
||||
/** Item IDs to suppress or deprioritize */
|
||||
suppress: string[]
|
||||
/** Item IDs to suppress or deprioritize */
|
||||
suppress: string[]
|
||||
|
||||
/** Ranking hints: item ID → relative importance (0-1) */
|
||||
rankingHints: Record<string, number>
|
||||
/** Ranking hints: item ID → relative importance (0-1) */
|
||||
rankingHints: Record<string, number>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -185,6 +186,7 @@ These run on every refresh. Fast, deterministic, and cover most of the ranking q
|
||||
**Anomaly detection.** Compare event start times against the user's historical distribution. A 6am meeting when the user never has meetings before 9am is a statistical outlier — flag it.
|
||||
|
||||
**User affinity scoring.** Track implicit signals per source type per time-of-day bucket:
|
||||
|
||||
- Dismissals: user swipes away weather cards → decay affinity for weather
|
||||
- Taps: user taps calendar items frequently → boost affinity for calendar
|
||||
- Dwell time: user reads TfL alerts carefully → boost
|
||||
@@ -193,9 +195,9 @@ No LLM needed. A simple decay/boost model:
|
||||
|
||||
```typescript
|
||||
interface UserAffinityModel {
|
||||
affinities: Record<string, Record<TimeBucket, number>>
|
||||
dismissalDecay: number
|
||||
tapBoost: number
|
||||
affinities: Record<string, Record<TimeBucket, number>>
|
||||
dismissalDecay: number
|
||||
tapBoost: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -309,7 +311,7 @@ There are three layers:
|
||||
|
||||
None of these have `if` statements. The LLM reads the feed, reads the user's memory, and decides what to say. Add a new source (Spotify, email, tasks) and the LLM automatically incorporates it — no new behavior code needed.
|
||||
|
||||
**Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the *decision-making* — what to extract, when to surface, how to phrase — is all LLM.
|
||||
**Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the _decision-making_ — what to extract, when to surface, how to phrase — is all LLM.
|
||||
|
||||
- Gentle Follow-up — needs: extraction pipeline after each conversation turn, `commitments` table. The LLM decides what counts as a commitment and when to remind.
|
||||
- Memory — needs: `memories` table, read/write API. The LLM decides what to remember and how to use it.
|
||||
@@ -321,7 +323,7 @@ None of these have `if` statements. The LLM reads the feed, reads the user's mem
|
||||
- Delegation — needs: confirmation flow, write-back infrastructure. The LLM decides what the user wants done.
|
||||
- Financial Awareness — needs: `financial_events` table, email extraction. The LLM decides what financial events matter.
|
||||
|
||||
**Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They *should* be rules because they need to be fast and predictable.
|
||||
**Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They _should_ be rules because they need to be fast and predictable.
|
||||
|
||||
- User affinity scoring — decay/boost math on tap/dismiss events
|
||||
- Deduplication — title + time matching across sources
|
||||
@@ -415,39 +417,38 @@ One per user, living in the `FeedEngineManager` on the backend:
|
||||
|
||||
```typescript
|
||||
class EnhancementManager {
|
||||
private cache: FeedEnhancement | null = null
|
||||
private lastInputHash: string | null = null
|
||||
private running = false
|
||||
private cache: FeedEnhancement | null = null
|
||||
private lastInputHash: string | null = null
|
||||
private running = false
|
||||
|
||||
async enhance(
|
||||
items: FeedItem[],
|
||||
context: AgentContext,
|
||||
): Promise<FeedEnhancement> {
|
||||
const hash = computeHash(items, context)
|
||||
async enhance(items: FeedItem[], context: AgentContext): Promise<FeedEnhancement> {
|
||||
const hash = computeHash(items, context)
|
||||
|
||||
// Nothing changed — return cache
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
// Nothing changed — return cache
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
// Already running — return stale cache
|
||||
if (this.running) {
|
||||
return this.cache ?? emptyEnhancement()
|
||||
}
|
||||
// Already running — return stale cache
|
||||
if (this.running) {
|
||||
return this.cache ?? emptyEnhancement()
|
||||
}
|
||||
|
||||
// Run in background, update cache when done
|
||||
this.running = true
|
||||
this.runHarness(items, context, hash)
|
||||
.then(enhancement => {
|
||||
this.cache = enhancement
|
||||
this.lastInputHash = hash
|
||||
this.notifySubscribers(enhancement)
|
||||
})
|
||||
.finally(() => { this.running = false })
|
||||
// Run in background, update cache when done
|
||||
this.running = true
|
||||
this.runHarness(items, context, hash)
|
||||
.then((enhancement) => {
|
||||
this.cache = enhancement
|
||||
this.lastInputHash = hash
|
||||
this.notifySubscribers(enhancement)
|
||||
})
|
||||
.finally(() => {
|
||||
this.running = false
|
||||
})
|
||||
|
||||
// Return stale cache immediately
|
||||
return this.cache ?? emptyEnhancement()
|
||||
}
|
||||
// Return stale cache immediately
|
||||
return this.cache ?? emptyEnhancement()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -522,7 +523,7 @@ These are `FeedSource` nodes that depend on calendar, tasks, weather, and other
|
||||
|
||||
#### Anticipatory Logistics
|
||||
|
||||
Works backward from events to tell you what you need to *do* to be ready.
|
||||
Works backward from events to tell you what you need to _do_ to be ready.
|
||||
|
||||
- Flight at 6am → "You need to leave by 4am, which means waking at 3:30. I'd suggest packing tonight."
|
||||
- Dinner at a new restaurant → "It's a 25-minute walk or 8-minute Uber. Street parking is difficult — there's a car park on the next street."
|
||||
@@ -579,7 +580,7 @@ Tracks loose ends — things you said but never wrote down as tasks.
|
||||
- "You told James you'd review his PR — it's been 3 days"
|
||||
- "You promised to call your mom this weekend"
|
||||
|
||||
The key difference from task tracking: this catches things that fell through the cracks *because* they were never formalized.
|
||||
The key difference from task tracking: this catches things that fell through the cracks _because_ they were never formalized.
|
||||
|
||||
**How intent extraction works:**
|
||||
|
||||
@@ -614,12 +615,14 @@ Long-term memory of interactions and preferences. Feeds into every other agent.
|
||||
A persistent profile that builds over time. Not an agent itself — a system that makes every other agent smarter.
|
||||
|
||||
Learns from:
|
||||
|
||||
- Explicit statements: "I prefer morning meetings"
|
||||
- Implicit behavior: user always dismisses evening suggestions
|
||||
- Feedback: user rates suggestions as helpful/not
|
||||
- Cross-source patterns: always books aisle seats, always picks the cheaper option
|
||||
|
||||
Used by:
|
||||
|
||||
- Proactive Agent suggests restaurants the user would actually like
|
||||
- Delegation Agent books the right kind of hotel room
|
||||
- Summary Agent uses the user's preferred level of detail
|
||||
@@ -648,27 +651,30 @@ Passive observation. The patterns aren't hardcoded — the LLM discovers them fr
|
||||
|
||||
```typescript
|
||||
interface DailySummary {
|
||||
date: string
|
||||
feedCheckTimes: string[] // when the user opened the feed
|
||||
itemTypeCounts: Record<string, number> // how many of each type appeared
|
||||
interactions: Array<{ // what the user tapped/dismissed
|
||||
itemType: string
|
||||
action: "tap" | "dismiss" | "dwell"
|
||||
time: string
|
||||
}>
|
||||
locations: Array<{ // where the user was throughout the day
|
||||
lat: number
|
||||
lng: number
|
||||
time: string
|
||||
}>
|
||||
calendarSummary: Array<{ // what events happened
|
||||
title: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location?: string
|
||||
attendees?: string[]
|
||||
}>
|
||||
weatherConditions: string[] // conditions seen throughout the day
|
||||
date: string
|
||||
feedCheckTimes: string[] // when the user opened the feed
|
||||
itemTypeCounts: Record<string, number> // how many of each type appeared
|
||||
interactions: Array<{
|
||||
// what the user tapped/dismissed
|
||||
itemType: string
|
||||
action: "tap" | "dismiss" | "dwell"
|
||||
time: string
|
||||
}>
|
||||
locations: Array<{
|
||||
// where the user was throughout the day
|
||||
lat: number
|
||||
lng: number
|
||||
time: string
|
||||
}>
|
||||
calendarSummary: Array<{
|
||||
// what events happened
|
||||
title: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location?: string
|
||||
attendees?: string[]
|
||||
}>
|
||||
weatherConditions: string[] // conditions seen throughout the day
|
||||
}
|
||||
```
|
||||
|
||||
@@ -678,20 +684,20 @@ interface DailySummary {
|
||||
|
||||
```typescript
|
||||
interface DiscoveredPattern {
|
||||
/** What the pattern is, in natural language */
|
||||
description: string
|
||||
/** How confident (0-1) */
|
||||
confidence: number
|
||||
/** When this pattern is relevant */
|
||||
relevance: {
|
||||
daysOfWeek?: number[]
|
||||
timeRange?: { start: string, end: string }
|
||||
conditions?: string[]
|
||||
}
|
||||
/** How this should affect the feed */
|
||||
feedImplication: string
|
||||
/** Suggested card to surface when pattern is relevant */
|
||||
suggestedAction?: string
|
||||
/** What the pattern is, in natural language */
|
||||
description: string
|
||||
/** How confident (0-1) */
|
||||
confidence: number
|
||||
/** When this pattern is relevant */
|
||||
relevance: {
|
||||
daysOfWeek?: number[]
|
||||
timeRange?: { start: string; end: string }
|
||||
conditions?: string[]
|
||||
}
|
||||
/** How this should affect the feed */
|
||||
feedImplication: string
|
||||
/** Suggested card to surface when pattern is relevant */
|
||||
suggestedAction?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -717,9 +723,9 @@ Maintains awareness of relationships and surfaces timely nudges.
|
||||
|
||||
Needs: contacts with birthday/anniversary data, calendar history for meeting frequency, email/message signals, optionally social media.
|
||||
|
||||
This is what makes an assistant feel like it *cares*. Most tools are transactional. This one remembers the people in your life.
|
||||
This is what makes an assistant feel like it _cares_. Most tools are transactional. This one remembers the people in your life.
|
||||
|
||||
Beyond frequency, the assistant can understand relationship *dynamics*:
|
||||
Beyond frequency, the assistant can understand relationship _dynamics_:
|
||||
|
||||
- "You and Sarah always have productive meetings. You and Alex tend to go off-track — maybe set a tighter agenda."
|
||||
- "You've cancelled on Tom three times — he might be feeling deprioritized."
|
||||
@@ -785,7 +791,7 @@ This is where the source graph pays off. All the data is already there — the a
|
||||
|
||||
#### Tone & Timing
|
||||
|
||||
Controls *when* and *how* information is delivered. The difference between useful and annoying.
|
||||
Controls _when_ and _how_ information is delivered. The difference between useful and annoying.
|
||||
|
||||
- Bad news before morning coffee? Hold it.
|
||||
- Three notifications in a row? Batch them.
|
||||
@@ -849,6 +855,7 @@ The primary interface. This isn't a feed query tool — it's the person you talk
|
||||
The user should be able to ask AELIS anything they'd ask a knowledgeable friend. Some questions are about their data. Most aren't.
|
||||
|
||||
**About their life (reads from the source graph):**
|
||||
|
||||
- "What's on my calendar tomorrow?"
|
||||
- "When's my next flight?"
|
||||
- "Do I have any conflicts this week?"
|
||||
@@ -856,6 +863,7 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
|
||||
- "Tell me more about this" (anchored to a feed item)
|
||||
|
||||
**About the world (falls through to web search):**
|
||||
|
||||
- "How do I unclog a drain?"
|
||||
- "What should I make with chicken and broccoli?"
|
||||
- "What's the best way to get from King's Cross to Heathrow?"
|
||||
@@ -864,6 +872,7 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
|
||||
- "What are some good date night restaurants in Shoreditch?"
|
||||
|
||||
**Contextual blend (graph + web):**
|
||||
|
||||
- "What's the dress code for The Ivy?" (calendar shows dinner there tonight)
|
||||
- "Will I need an umbrella?" (location + weather, but could also web-search venue for indoor/outdoor)
|
||||
- "What should I know before my meeting with Acme Corp?" (calendar + web search for company info)
|
||||
@@ -879,10 +888,12 @@ This is also where intent extraction happens for the Gentle Follow-up Agent. Eve
|
||||
The backbone for general knowledge. Makes AELIS a person you can ask things, not just a dashboard you look at.
|
||||
|
||||
**Reactive (user asks):**
|
||||
|
||||
- Recipe ideas, how-to questions, factual lookups, recommendations
|
||||
- Anything the source graph can't answer
|
||||
|
||||
**Proactive (agents trigger):**
|
||||
|
||||
- Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking
|
||||
- Feed shows a concert → pre-fetches setlist, venue details
|
||||
- Ambient Context checks for disruptions, closures, news
|
||||
@@ -949,7 +960,7 @@ Handles tasks the user delegates via natural language.
|
||||
|
||||
Requires write access to sources. Confirmation UX for anything destructive or costly.
|
||||
|
||||
**Implementation:** Extends the Query Agent. When the LLM determines the user wants to *do* something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent.
|
||||
**Implementation:** Extends the Query Agent. When the LLM determines the user wants to _do_ something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent.
|
||||
|
||||
#### Actions
|
||||
|
||||
|
||||
@@ -131,19 +131,19 @@ Feed items carry an optional `ui` field containing a json-render tree, and an op
|
||||
|
||||
```typescript
|
||||
interface FeedItem<TType, TData> {
|
||||
id: string
|
||||
type: TType
|
||||
timestamp: Date
|
||||
data: TData
|
||||
ui?: JsonRenderNode
|
||||
slots?: Record<string, Slot>
|
||||
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
|
||||
/** Tells the LLM what this slot wants — the source writes this */
|
||||
description: string
|
||||
/** LLM-filled text content, null until enhanced */
|
||||
content: string | null
|
||||
}
|
||||
```
|
||||
|
||||
@@ -238,28 +238,23 @@ The user never waits for the LLM. They see the feed instantly with the previous
|
||||
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]
|
||||
)
|
||||
),
|
||||
}))
|
||||
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(),
|
||||
}
|
||||
return {
|
||||
items: itemsWithSlots,
|
||||
userMemory: context.preferences,
|
||||
currentTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,29 +262,33 @@ 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"
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -299,27 +298,30 @@ A flat map of item ID → slot name → text content. Slots left null are unfill
|
||||
|
||||
```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": {}
|
||||
"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": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -329,42 +331,41 @@ 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
|
||||
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)
|
||||
async enhance(items: FeedItem[], context: AgentContext): Promise<EnhancementResult> {
|
||||
const hash = computeHash(items, context)
|
||||
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
if (this.running) {
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
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 })
|
||||
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()
|
||||
}
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
}
|
||||
|
||||
interface EnhancementResult {
|
||||
slotFills: Record<string, Record<string, string | null>>
|
||||
syntheticItems: FeedItem[]
|
||||
suppress: string[]
|
||||
rankingHints: Record<string, number>
|
||||
slotFills: Record<string, Record<string, string | null>>
|
||||
syntheticItems: FeedItem[]
|
||||
suppress: string[]
|
||||
rankingHints: Record<string, number>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -373,23 +374,20 @@ interface EnhancementResult {
|
||||
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
|
||||
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 }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
})
|
||||
return { ...item, slots: mergedSlots }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -24,16 +24,16 @@ The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persi
|
||||
|
||||
A `user_sources` table stores per-user source state:
|
||||
|
||||
| Column | Type | Description |
|
||||
| ------------ | ------------------------ | ------------------------------------------------------------ |
|
||||
| `id` | `uuid` PK | Row ID |
|
||||
| `user_id` | `text` FK → `user.id` | Owner |
|
||||
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
|
||||
| `enabled` | `boolean` | Whether this source is active in the user's feed |
|
||||
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
|
||||
| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
|
||||
| `created_at` | `timestamp with tz` | Row creation time |
|
||||
| `updated_at` | `timestamp with tz` | Last modification time |
|
||||
| Column | Type | Description |
|
||||
| ------------- | --------------------- | -------------------------------------------------------------- |
|
||||
| `id` | `uuid` PK | Row ID |
|
||||
| `user_id` | `text` FK → `user.id` | Owner |
|
||||
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
|
||||
| `enabled` | `boolean` | Whether this source is active in the user's feed |
|
||||
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime) |
|
||||
| `credentials` | `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
|
||||
| `created_at` | `timestamp with tz` | Row creation time |
|
||||
| `updated_at` | `timestamp with tz` | Last modification time |
|
||||
|
||||
- Unique constraint on `(user_id, source_id)` — one config row per source per user.
|
||||
- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time.
|
||||
@@ -50,11 +50,11 @@ A `user_sources` table stores per-user source state:
|
||||
|
||||
When a new user is created, seed `user_sources` rows for default sources:
|
||||
|
||||
| Source | Default config |
|
||||
| ------------------ | --------------------------------------------------------------- |
|
||||
| `aelis.location` | `{}` |
|
||||
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
|
||||
| `aelis.tfl` | `{ "lines": <all default lines> }` |
|
||||
| Source | Default config |
|
||||
| ---------------- | ----------------------------------------------------------- |
|
||||
| `aelis.location` | `{}` |
|
||||
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
|
||||
| `aelis.tfl` | `{ "lines": <all default lines> }` |
|
||||
|
||||
- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup.
|
||||
- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first.
|
||||
@@ -67,29 +67,35 @@ Each provider receives the Drizzle DB instance and queries `user_sources` intern
|
||||
|
||||
```typescript
|
||||
class TflSourceProvider implements FeedSourceProvider {
|
||||
constructor(private db: DrizzleDb, private apiKey: string) {}
|
||||
constructor(
|
||||
private db: DrizzleDb,
|
||||
private apiKey: string,
|
||||
) {}
|
||||
|
||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
||||
const row = await this.db.select()
|
||||
.from(userSources)
|
||||
.where(and(
|
||||
eq(userSources.userId, userId),
|
||||
eq(userSources.sourceId, "aelis.tfl"),
|
||||
eq(userSources.enabled, true),
|
||||
))
|
||||
.limit(1)
|
||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
||||
const row = await this.db
|
||||
.select()
|
||||
.from(userSources)
|
||||
.where(
|
||||
and(
|
||||
eq(userSources.userId, userId),
|
||||
eq(userSources.sourceId, "aelis.tfl"),
|
||||
eq(userSources.enabled, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row[0]) {
|
||||
throw new SourceDisabledError("aelis.tfl", userId)
|
||||
}
|
||||
if (!row[0]) {
|
||||
throw new SourceDisabledError("aelis.tfl", userId)
|
||||
}
|
||||
|
||||
const config = tflSourceConfig(row[0].config ?? {})
|
||||
if (config instanceof type.errors) {
|
||||
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
|
||||
}
|
||||
const config = tflSourceConfig(row[0].config ?? {})
|
||||
if (config instanceof type.errors) {
|
||||
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
|
||||
}
|
||||
|
||||
return new TflSource({ ...config, apiKey: this.apiKey })
|
||||
}
|
||||
return new TflSource({ ...config, apiKey: this.apiKey })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -210,16 +216,19 @@ _`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `lo
|
||||
## Dependencies
|
||||
|
||||
**Add:**
|
||||
|
||||
- `drizzle-orm`
|
||||
- `drizzle-kit` (dev)
|
||||
|
||||
**Remove:**
|
||||
|
||||
- `pg`
|
||||
- `@types/pg` (dev)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**Add to `.env.example`:**
|
||||
|
||||
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
|
||||
|
||||
## Open Questions (Deferred)
|
||||
|
||||
@@ -39,14 +39,14 @@ Source IDs use reverse domain notation. Built-in sources use `aelis.<name>`. Thi
|
||||
|
||||
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`.
|
||||
|
||||
| Source ID | Action IDs |
|
||||
| --------------- | -------------------------------------------------------------- |
|
||||
| Source ID | Action IDs |
|
||||
| ---------------- | -------------------------------------------------------------- |
|
||||
| `aelis.location` | `update-location` (migrated from `pushLocation()`) |
|
||||
| `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
|
||||
| `aelis.weather` | _(none)_ |
|
||||
| `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` |
|
||||
| `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` |
|
||||
| `aelis.calendar` | `rsvp`, `create-event` |
|
||||
| `com.todoist` | `complete-task`, `snooze-task` |
|
||||
| `com.todoist` | `complete-task`, `snooze-task` |
|
||||
|
||||
This means existing source packages need their `id` updated (e.g., `"location"` → `"aelis.location"`).
|
||||
|
||||
@@ -241,8 +241,16 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
type: "View",
|
||||
className: "flex-1",
|
||||
children: [
|
||||
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
|
||||
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
|
||||
{
|
||||
type: "Text",
|
||||
className: "font-semibold text-black dark:text-white",
|
||||
text: track.name,
|
||||
},
|
||||
{
|
||||
type: "Text",
|
||||
className: "text-sm text-gray-500 dark:text-gray-400",
|
||||
text: track.artist,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -261,8 +269,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
||||
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
|
||||
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
||||
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
||||
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
|
||||
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
|
||||
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
|
||||
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
|
||||
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
||||
8. `FeedEngine.listActions()` aggregates actions from all sources
|
||||
9. Existing tests pass unchanged (all changes are additive)
|
||||
|
||||
Reference in New Issue
Block a user