Compare commits

..

2 Commits

Author SHA1 Message Date
ba0450d0aa feat(caldav): add slot support for feed items
Adds three LLM-fillable slots to every CalDav feed item:
insight, preparation, and crossSource. Slot prompts are
stored in separate .txt files under src/prompts/ with
few-shot examples to steer the LLM away from restating
event details.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-03 01:32:09 +00:00
d7d8dba2de fix(caldav): expand recurring events in range
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-03 01:30:49 +00:00
7 changed files with 103 additions and 21 deletions

View File

@@ -534,4 +534,68 @@ describe("computeSignals", () => {
})
})
describe("CalDavSource feed item slots", () => {
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
test("timed event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
const slot = item.slots![name]!
expect(slot.content).toBeNull()
expect(typeof slot.description).toBe("string")
expect(slot.description.length).toBeGreaterThan(0)
}
})
test("all-day event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.data.isAllDay).toBe(true)
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
expect(item.slots![name]!.content).toBeNull()
}
})
test("cancelled event has all three slots with null content", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
const item = items[0]!
expect(item.data.status).toBe("cancelled")
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
for (const name of EXPECTED_SLOT_NAMES) {
expect(item.slots![name]!.content).toBeNull()
}
})
})

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
@@ -7,6 +7,9 @@ import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.t
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
import crossSourcePrompt from "./prompts/cross-source.txt"
import insightPrompt from "./prompts/insight.txt"
import preparationPrompt from "./prompts/preparation.txt"
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
// -- Source options --
@@ -340,6 +343,14 @@ export function computeSignals(
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
function createEventSlots(): Record<string, Slot> {
return {
insight: { description: insightPrompt, content: null },
preparation: { description: preparationPrompt, content: null },
crossSource: { description: crossSourcePrompt, content: null },
}
}
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
@@ -347,5 +358,6 @@ function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): C
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),
slots: createEventSlots(),
}
}

View File

@@ -14,13 +14,6 @@ export interface ICalTimeRange {
end: Date
}
/**
* 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.
@@ -113,23 +106,11 @@ export function parseICalEvents(
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.
// Expand recurring event occurrences within the time range
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

View File

@@ -0,0 +1,8 @@
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
Examples:
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
- "Northern line has delays — leave 15 minutes early"
- "your next event is across town — the 40 min gap may not be enough"
- null (indoor guitar class with wind outside — weather doesn't affect the event)
- null

View File

@@ -0,0 +1,7 @@
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
Examples:
- "you have 2 hours free before this starts"
- "all 8 attendees accepted — expect a full room"
- "third time this has been rescheduled"
- null

View File

@@ -0,0 +1,6 @@
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
Examples:
- "different building from your previous meeting — allow travel time"
- "recurring meeting you declined last week — check if you need to attend"
- null

View File

@@ -0,0 +1,4 @@
declare module "*.txt" {
const content: string
export default content
}