Compare commits

..

3 Commits

Author SHA1 Message Date
c9be3b190c fix: use TimeRelevance consts instead of strings
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 23:03:57 +00:00
0f8b1ec4eb fix: clamp boost values to [-1, 1]
Additive layers can exceed the documented range.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:58:25 +00:00
4f55439f77 feat: add TimeOfDayEnhancer post-processor
Rule-based feed post-processor that reranks items
by time period, day type, and calendar proximity.

New package: @aris/feed-enhancers

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:57:46 +00:00
16 changed files with 16 additions and 552 deletions

View File

@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { FeedItem, Slot } from "./feed"
describe("FeedItem slots", () => {
test("FeedItem without slots is valid", () => {
const item: FeedItem<"test", { value: number }> = {
id: "test-1",
type: "test",
timestamp: new Date(),
data: { value: 42 },
}
expect(item.slots).toBeUndefined()
})
test("FeedItem with unfilled slots", () => {
const item: FeedItem<"weather", { temp: number }> = {
id: "weather-1",
type: "weather",
timestamp: new Date(),
data: { temp: 18 },
slots: {
insight: {
description: "A short contextual insight about the current weather",
content: null,
},
"cross-source": {
description: "Connection between weather and calendar events",
content: null,
},
},
}
expect(item.slots).toBeDefined()
expect(Object.keys(item.slots!)).toEqual(["insight", "cross-source"])
expect(item.slots!.insight!.content).toBeNull()
expect(item.slots!["cross-source"]!.content).toBeNull()
})
test("FeedItem with filled slots", () => {
const item: FeedItem<"weather", { temp: number }> = {
id: "weather-1",
type: "weather",
timestamp: new Date(),
data: { temp: 18 },
slots: {
insight: {
description: "A short contextual insight about the current weather",
content: "Rain after 3pm — grab a jacket before your walk",
},
},
}
expect(item.slots!.insight!.content).toBe("Rain after 3pm — grab a jacket before your walk")
})
test("Slot interface enforces required fields", () => {
const slot: Slot = {
description: "Test slot description",
content: null,
}
expect(slot.description).toBe("Test slot description")
expect(slot.content).toBeNull()
const filledSlot: Slot = {
description: "Test slot description",
content: "Filled content",
}
expect(filledSlot.content).toBe("Filled content")
})
test("FeedItem with empty slots record", () => {
const item: FeedItem<"test", { value: number }> = {
id: "test-1",
type: "test",
timestamp: new Date(),
data: { value: 1 },
slots: {},
}
expect(item.slots).toEqual({})
expect(Object.keys(item.slots!)).toHaveLength(0)
})
})

View File

@@ -23,20 +23,6 @@ export interface FeedItemSignals {
timeRelevance?: TimeRelevance
}
/**
* A named slot for LLM-fillable content on a feed item.
*
* Sources declare slots with a description that tells the LLM what content
* to generate. The enhancement harness fills `content` asynchronously;
* until then it remains `null`.
*/
export interface Slot {
/** Tells the LLM what this slot wants — written by the source */
description: string
/** LLM-filled text content, null until enhanced */
content: string | null
}
/**
* A single item in the feed.
*
@@ -50,12 +36,6 @@ export interface Slot {
* timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* slots: {
* insight: {
* description: "A short contextual insight about the current weather",
* content: null,
* },
* },
* }
* ```
*/
@@ -73,6 +53,4 @@ export interface FeedItem<
data: TData
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
signals?: FeedItemSignals
/** Named slots for LLM-fillable content. Keys are slot names. */
slots?: Record<string, Slot>
}

View File

@@ -7,7 +7,7 @@ export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
export type { FeedItem, FeedItemSignals } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source

View File

@@ -1,12 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,13 +0,0 @@
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,13 +3,8 @@
*
* 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"
@@ -56,9 +51,6 @@ 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(", ")}`)
}
@@ -70,11 +62,3 @@ 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("expands recurring events within the time range", async () => {
test("handles recurring events with exceptions", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{
@@ -218,42 +218,21 @@ 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")))
// 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"))
})
expect(items).toHaveLength(2)
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 base = items.find((i) => i.data.title === "Weekly Sync")
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
// Jan 15 base occurrence
expect(base.length).toBeGreaterThanOrEqual(1)
expect(base).toBeDefined()
expect(base!.data.recurrenceId).toBeNull()
// Jan 22 exception replaces the base occurrence
expect(exception).toBeDefined()
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-")
})
test("caches events within the same refresh cycle", async () => {
@@ -533,69 +512,3 @@ describe("computeSignals", () => {
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
})
})
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, Slot } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
@@ -7,9 +7,6 @@ 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 --
@@ -187,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, { start, end })
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
@@ -343,14 +340,6 @@ 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}` : ""}`,
@@ -358,6 +347,5 @@ function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): C
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),
slots: createEventSlots(),
}
}

View File

@@ -105,94 +105,3 @@ 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,172 +9,21 @@ import {
type CalDavEventData,
} from "./types.ts"
export interface ICalTimeRange {
start: Date
end: Date
}
/**
* Parses a raw iCalendar string and extracts VEVENT components
* Parses a raw iCalendar string and extracts all 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,
timeRange?: ICalTimeRange,
): CalDavEventData[] {
export function parseICalEvents(icsData: string, calendarName: string | null): 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
const iter = masterEvent.iterator()
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
while (next) {
// 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,
}
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName),
)
}
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, type ICalTimeRange } from "./ical-parser.ts"
export { parseICalEvents } from "./ical-parser.ts"
export {
AttendeeRole,
AttendeeStatus,

View File

@@ -1,8 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,6 +0,0 @@
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

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