2026-03-10 19:19:23 +00:00
|
|
|
|
import type { FeedItem } from "@aelis/core"
|
2026-03-05 02:01:30 +00:00
|
|
|
|
|
2026-03-10 19:19:23 +00:00
|
|
|
|
import { CalDavFeedItemType } from "@aelis/source-caldav"
|
|
|
|
|
|
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
|
2026-03-05 02:01:30 +00:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|