Compare commits

..

4 Commits

Author SHA1 Message Date
2b1a50349c 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>
2026-03-05 01:48:24 +00:00
bb92c9f227 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>
2026-03-05 01:20:34 +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
29 changed files with 1568 additions and 57 deletions

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

@@ -10,9 +10,12 @@
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",

View File

@@ -0,0 +1,51 @@
import type { FeedItem } from "@aris/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,150 @@
import type { FeedItem } from "@aris/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",
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,41 @@
import type { FeedItem } from "@aris/core"
import type { EnhancementResult } from "./schema.ts"
/**
* 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,
type: synthetic.type,
timestamp: currentTime,
data: { text: synthetic.text },
})
}
return merged
}

View File

@@ -0,0 +1,167 @@
import type { FeedItem } from "@aris/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",
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 "@aris/core"
import { CalDavFeedItemType } from "@aris/source-caldav"
import { CalendarFeedItemType } from "@aris/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 ARIS, 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,89 @@
import { describe, expect, test } from "bun:test"
import { emptyEnhancementResult, 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([])
})
})

View File

@@ -0,0 +1,89 @@
import { type } from "arktype"
const syntheticItemSchema = type({
id: "string",
type: "string",
text: "string",
})
const enhancementResultSchema = type({
slotFills: "Record<string, Record<string, string | null>>",
syntheticItems: syntheticItemSchema.array(),
})
export type SyntheticItem = typeof syntheticItemSchema.infer
export type EnhancementResult = typeof enhancementResultSchema.infer
/**
* JSON Schema passed to OpenRouter's structured output.
* OpenRouter doesn't support arktype, so this is maintained separately.
*
* ⚠️ Must stay in sync with enhancementResultSchema 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 = enhancementResultSchema(parsed)
if (result instanceof type.errors) {
return null
}
return result
}
export function emptyEnhancementResult(): EnhancementResult {
return { slotFills: {}, syntheticItems: [] }
}

View File

@@ -47,7 +47,7 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager([])
const manager = new UserSessionManager({ providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
@@ -65,7 +65,9 @@ describe("GET /api/feed", () => {
data: { value: 42 },
},
]
const manager = new UserSessionManager([() => createStubSource("test", items)])
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
@@ -95,7 +97,9 @@ describe("GET /api/feed", () => {
data: { fresh: true },
},
]
const manager = new UserSessionManager([() => createStubSource("test", items)])
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// No prior refresh — lastFeed() returns null, handler should call refresh()
@@ -125,7 +129,7 @@ describe("GET /api/feed", () => {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager([() => failingSource])
const manager = new UserSessionManager({ providers: [() => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")

View File

@@ -5,7 +5,11 @@ 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 } }
type Env = {
Variables: {
sessionManager: UserSessionManager
}
}
interface FeedHttpHandlersDeps {
sessionManager: UserSessionManager
@@ -29,7 +33,7 @@ async function handleGetFeed(c: Context<Env>) {
const sessionManager = c.get("sessionManager")
const session = sessionManager.getOrCreate(user.id)
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
const feed = await session.feed()
return c.json({
items: feed.items,

View File

@@ -3,13 +3,29 @@ import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { requireSession } from "./auth/session-middleware.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.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"
function main() {
const sessionManager = new UserSessionManager([
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: {
@@ -19,14 +35,19 @@ function main() {
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
}),
])
],
feedEnhancer,
})
const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app)
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware: requireSession,
})
registerLocationHttpHandlers(app, { sessionManager })
return app

View File

@@ -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,7 +39,7 @@ 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")
@@ -51,7 +51,7 @@ describe("UserSessionManager", () => {
})
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,7 +77,9 @@ 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")
@@ -86,7 +88,9 @@ describe("UserSessionManager", () => {
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")
@@ -95,7 +99,7 @@ describe("UserSessionManager", () => {
})
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,7 +111,7 @@ 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", {
@@ -122,7 +126,7 @@ describe("UserSessionManager", () => {
})
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")
@@ -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")

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

@@ -1,11 +1,11 @@
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
import { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string): FeedSource {
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
@@ -18,7 +18,7 @@ function createStubSource(id: string): FeedSource {
return null
},
async fetchItems() {
return []
return items
},
}
}
@@ -70,3 +70,141 @@ describe("UserSession", () => {
expect(location.lastLocation!.lat).toBe(51.5)
})
})
describe("UserSession.feed", () => {
test("returns feed items without enhancer", async () => {
const items: FeedItem[] = [
{
id: "item-1",
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",
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",
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",
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",
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",
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

@@ -1,24 +1,104 @@
import { FeedEngine, type FeedSource } from "@aris/core"
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/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[]) {
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

@@ -18,9 +18,12 @@
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",
@@ -543,6 +546,8 @@
"@oclif/screen": ["@oclif/screen@3.0.8", "", {}, "sha512-yx6KAqlt3TAHBduS2fMQtJDL2ufIHnDRArrJEOoTTuizxqmjLT+psGYOHpmMl3gvQpFJ11Hs76guUUktzAF9Bg=="],
"@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="],
"@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="],
"@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="],

View File

@@ -61,10 +61,7 @@ function partsEqual(a: unknown, b: unknown): boolean {
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key) =>
partsEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
),
partsEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
)
}
return false

View File

@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:daily-allday-001@test
DTSTART;VALUE=DATE:20260112
DTEND;VALUE=DATE:20260113
SUMMARY:Daily Reminder
RRULE:FREQ=DAILY;COUNT=7
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:weekly-exc-001@test
DTSTART:20260101T140000Z
DTEND:20260101T150000Z
SUMMARY:Standup
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:weekly-exc-001@test
RECURRENCE-ID:20260115T140000Z
DTSTART:20260115T160000Z
DTEND:20260115T170000Z
SUMMARY:Standup (rescheduled)
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:weekly-001@test
DTSTART:20260101T100000Z
DTEND:20260101T110000Z
SUMMARY:Weekly Team Meeting
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10
LOCATION:Room B
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -3,8 +3,13 @@
*
* Usage:
* bun run test-live.ts
*
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
*/
import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts"
@@ -51,6 +56,9 @@ for (const item of items) {
console.log(` Status: ${item.data.status ?? "(none)"}`)
console.log(` Urgency: ${item.signals?.urgency}`)
console.log(` Relevance: ${item.signals?.timeRelevance}`)
if (item.slots) {
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
}
if (item.data.attendees.length > 0) {
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
}
@@ -62,3 +70,11 @@ for (const item of items) {
if (items.length === 0) {
console.log("(no events found in the time window)")
}
// Write feed items to .cache for slot testing
const cacheDir = join(import.meta.dir, ".cache")
mkdirSync(cacheDir, { recursive: true })
const outPath = join(cacheDir, "feed-items.json")
writeFileSync(outPath, JSON.stringify(items, null, 2))
console.log(`\nFeed items written to ${outPath}`)

View File

@@ -208,7 +208,7 @@ describe("CalDavSource", () => {
expect(items[0]!.data.calendarName).toBeNull()
})
test("handles recurring events with exceptions", async () => {
test("expands recurring events within the time range", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{
@@ -218,21 +218,42 @@ describe("CalDavSource", () => {
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
// lookAheadDays=0 → range is Jan 15 only
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
expect(items).toHaveLength(2)
// Only the Jan 15 occurrence falls in the single-day window
expect(items).toHaveLength(1)
expect(items[0]!.data.title).toBe("Weekly Sync")
expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z"))
})
const base = items.find((i) => i.data.title === "Weekly Sync")
test("includes exception overrides when they fall in range", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{
url: "/cal/work/recurring.ics",
data: loadFixture("recurring-event.ics"),
},
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
// lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception
const source = createSource(client, 8)
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
const base = items.filter((i) => i.data.title === "Weekly Sync")
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
expect(base).toBeDefined()
expect(base!.data.recurrenceId).toBeNull()
// Jan 15 base occurrence
expect(base.length).toBeGreaterThanOrEqual(1)
// Jan 22 exception replaces the base occurrence
expect(exception).toBeDefined()
expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-")
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
})
test("caches events within the same refresh cycle", async () => {
@@ -512,3 +533,5 @@ describe("computeSignals", () => {
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
})
})

View File

@@ -184,7 +184,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
const events = parseICalEvents(obj.data, calendarName, { start, end })
for (const event of events) {
allEvents.push(event)
}

View File

@@ -105,3 +105,94 @@ describe("parseICalEvents", () => {
expect(events[0]!.status).toBe("cancelled")
})
})
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
test("expands weekly recurring event into occurrences within range", () => {
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
// Query window: Jan 14 Jan 23 → should get Jan 15 and Jan 22
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-23T00:00:00Z"),
})
expect(events).toHaveLength(2)
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
// All occurrences share the same UID and metadata
for (const event of events) {
expect(event.uid).toBe("weekly-001@test")
expect(event.title).toBe("Weekly Team Meeting")
expect(event.location).toBe("Room B")
expect(event.calendarName).toBe("Work")
}
})
test("returns empty array when no occurrences fall in range", () => {
// Query window: Dec 2025 — before the first occurrence
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
start: new Date("2025-12-01T00:00:00Z"),
end: new Date("2025-12-31T00:00:00Z"),
})
expect(events).toHaveLength(0)
})
test("applies exception overrides during expansion", () => {
// weekly-recurring-with-exception.ics:
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
// Query window: Jan 14 Jan 16 → should get the exception occurrence for Jan 15
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(events).toHaveLength(1)
expect(events[0]!.title).toBe("Standup (rescheduled)")
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
})
test("expands recurring all-day events", () => {
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
// Query window: Jan 14 Jan 17 → should get Jan 14, 15, 16
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(events).toHaveLength(3)
for (const event of events) {
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Daily Reminder")
}
})
test("non-recurring events are filtered by range", () => {
// single-event.ics: 2026-01-15T14:00 15:00
// Query window that includes it
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-15T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(included).toHaveLength(1)
// Query window that excludes it
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-16T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(excluded).toHaveLength(0)
})
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
// Legacy behavior: no expansion, just returns the VEVENT components as-is
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
})
})

View File

@@ -9,21 +9,191 @@ import {
type CalDavEventData,
} from "./types.ts"
export interface ICalTimeRange {
start: Date
end: Date
}
/**
* Parses a raw iCalendar string and extracts all VEVENT components
* Safety cap to prevent runaway iteration on pathological recurrence rules.
* Each iteration is pure date math (no I/O), so a high cap is fine.
* 10,000 covers a daily event with DTSTART ~27 years in the past.
*/
const MAX_RECURRENCE_ITERATIONS = 10_000
/**
* Parses a raw iCalendar string and extracts VEVENT components
* into CalDavEventData objects.
*
* When a timeRange is provided, recurring events are expanded into
* individual occurrences within that range. Without a timeRange,
* each VEVENT component is returned as-is (legacy behavior).
*
* @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to
* @param timeRange - When set, expand recurrences and filter to this window
*/
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
export function parseICalEvents(
icsData: string,
calendarName: string | null,
timeRange?: ICalTimeRange,
): CalDavEventData[] {
const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent")
if (!timeRange) {
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName),
)
}
// Group VEVENTs by UID: master + exceptions
const byUid = new Map<
string,
{
master: InstanceType<typeof ICAL.Component> | null
exceptions: InstanceType<typeof ICAL.Component>[]
}
>()
for (const vevent of vevents as InstanceType<typeof ICAL.Component>[]) {
const uid = vevent.getFirstPropertyValue("uid") as string | null
if (!uid) continue
const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null
let group = byUid.get(uid)
if (!group) {
group = { master: null, exceptions: [] }
byUid.set(uid, group)
}
if (hasRecurrenceId) {
group.exceptions.push(vevent)
} else {
group.master = vevent
}
}
const results: CalDavEventData[] = []
const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true)
const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true)
for (const group of byUid.values()) {
if (!group.master) {
// Orphan exceptions — parse them directly if they fall in range
for (const exc of group.exceptions) {
const parsed = parseVEvent(exc, calendarName)
if (overlapsRange(parsed, timeRange)) {
results.push(parsed)
}
}
continue
}
const masterEvent = new ICAL.Event(group.master)
// Register exceptions so getOccurrenceDetails resolves them
for (const exc of group.exceptions) {
masterEvent.relateException(exc)
}
if (!masterEvent.isRecurring()) {
const parsed = parseVEvent(group.master, calendarName)
if (overlapsRange(parsed, timeRange)) {
results.push(parsed)
}
// Also include standalone exceptions for non-recurring events
for (const exc of group.exceptions) {
const parsedExc = parseVEvent(exc, calendarName)
if (overlapsRange(parsedExc, timeRange)) {
results.push(parsedExc)
}
}
continue
}
// Expand recurring event occurrences within the time range.
// The iterator must start from DTSTART (not rangeStart) because
// ical.js needs to walk the recurrence rule grid from the original
// anchor. We cap iterations to avoid runaway expansion on
// pathological rules.
const iter = masterEvent.iterator()
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
let iterations = 0
while (next) {
if (++iterations > MAX_RECURRENCE_ITERATIONS) {
console.warn(
`[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`,
)
break
}
// Stop once we're past the range end
if (next.compare(rangeEnd) >= 0) break
const details = masterEvent.getOccurrenceDetails(next)
const occEnd = details.endDate
// Skip occurrences that end before the range starts
if (occEnd.compare(rangeStart) <= 0) {
next = iter.next()
continue
}
const occEvent = details.item
const occComponent = occEvent.component
const parsed = parseVEventWithDates(
occComponent,
calendarName,
details.startDate.toJSDate(),
details.endDate.toJSDate(),
details.recurrenceId ? details.recurrenceId.toString() : null,
)
results.push(parsed)
next = iter.next()
}
}
return results
}
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
return event.startDate < range.end && event.endDate > range.start
}
/**
* Parse a VEVENT component, overriding start/end/recurrenceId with
* values from recurrence expansion.
*/
function parseVEventWithDates(
vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null,
startDate: Date,
endDate: Date,
recurrenceId: string | null,
): CalDavEventData {
const event = new ICAL.Event(vevent)
return {
uid: event.uid ?? "",
title: event.summary ?? "",
startDate,
endDate,
isAllDay: event.startDate?.isDate ?? false,
location: event.location ?? null,
description: event.description ?? null,
calendarName,
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
alarms: parseAlarms(vevent),
recurrenceId,
}
}
function parseVEvent(

View File

@@ -1,6 +1,6 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents } from "./ical-parser.ts"
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
export {
AttendeeRole,
AttendeeStatus,