mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
4 Commits
feat/calda
...
2b1a50349c
| Author | SHA1 | Date | |
|---|---|---|---|
|
2b1a50349c
|
|||
|
bb92c9f227
|
|||
| 31d5aa8d50 | |||
| de29e44a08 |
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
51
apps/aris-backend/src/enhancement/enhance-feed.ts
Normal file
51
apps/aris-backend/src/enhancement/enhance-feed.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
apps/aris-backend/src/enhancement/llm-client.ts
Normal file
71
apps/aris-backend/src/enhancement/llm-client.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
150
apps/aris-backend/src/enhancement/merge.test.ts
Normal file
150
apps/aris-backend/src/enhancement/merge.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
41
apps/aris-backend/src/enhancement/merge.ts
Normal file
41
apps/aris-backend/src/enhancement/merge.ts
Normal 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
|
||||
}
|
||||
167
apps/aris-backend/src/enhancement/prompt-builder.test.ts
Normal file
167
apps/aris-backend/src/enhancement/prompt-builder.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
218
apps/aris-backend/src/enhancement/prompt-builder.ts
Normal file
218
apps/aris-backend/src/enhancement/prompt-builder.ts
Normal 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")
|
||||
}
|
||||
21
apps/aris-backend/src/enhancement/prompts/system.txt
Normal file
21
apps/aris-backend/src/enhancement/prompts/system.txt
Normal 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.
|
||||
89
apps/aris-backend/src/enhancement/schema.test.ts
Normal file
89
apps/aris-backend/src/enhancement/schema.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
89
apps/aris-backend/src/enhancement/schema.ts
Normal file
89
apps/aris-backend/src/enhancement/schema.ts
Normal 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: [] }
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,30 +3,51 @@ 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([
|
||||
() => new LocationSource(),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||
},
|
||||
}),
|
||||
])
|
||||
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: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -534,68 +534,4 @@ 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 --
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
4
packages/aris-source-caldav/src/text.d.ts
vendored
4
packages/aris-source-caldav/src/text.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
declare module "*.txt" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
181
packages/aris-source-weatherkit/scripts/query.ts
Normal file
181
packages/aris-source-weatherkit/scripts/query.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Interactive CLI script to query WeatherKit directly.
|
||||
* Prompts for credentials, coordinates, and optional settings,
|
||||
* then prints the raw API response and processed feed items.
|
||||
* Caches credentials locally and writes response JSON to a file.
|
||||
*
|
||||
* Usage: bun packages/aris-source-weatherkit/scripts/query.ts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { createInterface } from "node:readline/promises"
|
||||
|
||||
import { Context } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import { DefaultWeatherKitClient } from "../src/weatherkit"
|
||||
import { WeatherSource, Units } from "../src/weather-source"
|
||||
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const CACHE_DIR = join(SCRIPT_DIR, ".cache")
|
||||
const CREDS_PATH = join(CACHE_DIR, "credentials.json")
|
||||
|
||||
interface CachedCredentials {
|
||||
teamId: string
|
||||
serviceId: string
|
||||
keyId: string
|
||||
privateKey: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
function loadCachedCredentials(): CachedCredentials | null {
|
||||
if (!existsSync(CREDS_PATH)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(CREDS_PATH, "utf-8")) as CachedCredentials
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveCachedCredentials(creds: CachedCredentials): void {
|
||||
mkdirSync(CACHE_DIR, { recursive: true })
|
||||
writeFileSync(CREDS_PATH, JSON.stringify(creds))
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
||||
|
||||
async function prompt(question: string, defaultValue?: string): Promise<string> {
|
||||
const suffix = defaultValue ? ` [${defaultValue}]` : ""
|
||||
const answer = await rl.question(`${question}${suffix}: `)
|
||||
return answer.trim() || defaultValue || ""
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("=== WeatherKit Query Tool ===\n")
|
||||
|
||||
const cached = loadCachedCredentials()
|
||||
|
||||
let teamId: string
|
||||
let serviceId: string
|
||||
let keyId: string
|
||||
let privateKey: string
|
||||
|
||||
if (cached) {
|
||||
console.log(`Using cached credentials from ${CREDS_PATH}`)
|
||||
console.log(` Team ID: ${cached.teamId}`)
|
||||
console.log(` Service ID: ${cached.serviceId}`)
|
||||
console.log(` Key ID: ${cached.keyId}\n`)
|
||||
|
||||
const useCached = await prompt("Use cached credentials? (y/n)", "y")
|
||||
if (useCached.toLowerCase() === "y") {
|
||||
teamId = cached.teamId
|
||||
serviceId = cached.serviceId
|
||||
keyId = cached.keyId
|
||||
privateKey = cached.privateKey
|
||||
} else {
|
||||
;({ teamId, serviceId, keyId, privateKey } = await promptCredentials())
|
||||
}
|
||||
} else {
|
||||
console.log(`Credentials will be cached to ${CREDS_PATH}\n`)
|
||||
;({ teamId, serviceId, keyId, privateKey } = await promptCredentials())
|
||||
}
|
||||
|
||||
// Location
|
||||
const defaultLat = cached?.lat?.toString() ?? "37.7749"
|
||||
const defaultLng = cached?.lng?.toString() ?? "-122.4194"
|
||||
const lat = parseFloat(await prompt("Latitude", defaultLat))
|
||||
const lng = parseFloat(await prompt("Longitude", defaultLng))
|
||||
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
console.error("Invalid coordinates")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const credentials = { privateKey, keyId, teamId, serviceId }
|
||||
saveCachedCredentials({ ...credentials, lat, lng })
|
||||
|
||||
// Options
|
||||
const unitsInput = await prompt("Units (metric/imperial)", "metric")
|
||||
const units = unitsInput === "imperial" ? Units.imperial : Units.metric
|
||||
|
||||
// Raw API query
|
||||
console.log("\n--- Raw WeatherKit Response ---\n")
|
||||
const client = new DefaultWeatherKitClient(credentials)
|
||||
const raw = await client.fetch({ lat, lng })
|
||||
console.log(JSON.stringify(raw, null, 2))
|
||||
|
||||
// Write JSON to file
|
||||
const outPath = join(CACHE_DIR, "response.json")
|
||||
writeFileSync(outPath, JSON.stringify(raw))
|
||||
console.log(`\nResponse written to ${outPath}`)
|
||||
|
||||
// Processed feed items via WeatherSource
|
||||
console.log("\n--- Processed Feed Items ---\n")
|
||||
const source = new WeatherSource({ client, units })
|
||||
const context = new Context()
|
||||
context.set([[LocationKey, { lat, lng, accuracy: 10, timestamp: new Date() }]])
|
||||
|
||||
const items = await source.fetchItems(context)
|
||||
for (const item of items) {
|
||||
console.log(`[${item.type}] ${item.id}`)
|
||||
console.log(` signals: ${JSON.stringify(item.signals)}`)
|
||||
if (item.slots) {
|
||||
console.log(` slots:`)
|
||||
for (const [name, slot] of Object.entries(item.slots)) {
|
||||
console.log(` ${name}: "${slot.description}" -> ${slot.content ?? "(unfilled)"}`)
|
||||
}
|
||||
}
|
||||
console.log(` data: ${JSON.stringify(item.data, null, 4)}`)
|
||||
console.log()
|
||||
}
|
||||
|
||||
const feedPath = join(CACHE_DIR, "feed-items.json")
|
||||
writeFileSync(feedPath, JSON.stringify(items, null, 2))
|
||||
console.log(`Feed items written to ${feedPath}`)
|
||||
console.log(`Total: ${items.length} items`)
|
||||
rl.close()
|
||||
}
|
||||
|
||||
async function promptCredentials(): Promise<CachedCredentials> {
|
||||
const teamId = await prompt("Apple Team ID")
|
||||
if (!teamId) {
|
||||
console.error("Team ID is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serviceId = await prompt("Service ID")
|
||||
if (!serviceId) {
|
||||
console.error("Service ID is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const keyId = await prompt("Key ID")
|
||||
if (!keyId) {
|
||||
console.error("Key ID is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\nPaste your private key (PEM format). Enter an empty line when done:")
|
||||
const keyLines: string[] = []
|
||||
for await (const line of rl) {
|
||||
if (line.trim() === "") break
|
||||
keyLines.push(line)
|
||||
}
|
||||
const privateKey = keyLines.join("\n")
|
||||
if (!privateKey) {
|
||||
console.error("Private key is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return { teamId, serviceId, keyId, privateKey }
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err)
|
||||
rl.close()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
Max 12 words. Plain language, no hedging. Now + what's next.
|
||||
|
||||
Examples:
|
||||
- "Clear tonight, warming up. Rain by Saturday."
|
||||
- "Clearing soon with strong winds overnight. Light rain Thursday."
|
||||
- "Sunny all day. Grab sunscreen."
|
||||
- "Cloudy tonight, warming to 15°. Rain Monday."
|
||||
@@ -176,6 +176,34 @@ describe("WeatherSource", () => {
|
||||
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("current weather item has insight slot", async () => {
|
||||
const source = new WeatherSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await source.fetchItems(context)
|
||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
||||
|
||||
expect(currentItem).toBeDefined()
|
||||
expect(currentItem!.slots).toBeDefined()
|
||||
expect(currentItem!.slots!.insight).toBeDefined()
|
||||
expect(currentItem!.slots!.insight!.description).toBeString()
|
||||
expect(currentItem!.slots!.insight!.description.length).toBeGreaterThan(0)
|
||||
expect(currentItem!.slots!.insight!.content).toBeNull()
|
||||
})
|
||||
|
||||
test("non-current items do not have slots", async () => {
|
||||
const source = new WeatherSource({ client: mockClient })
|
||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||
|
||||
const items = await source.fetchItems(context)
|
||||
const nonCurrentItems = items.filter((i) => i.type !== WeatherFeedItemType.Current)
|
||||
|
||||
expect(nonCurrentItems.length).toBeGreaterThan(0)
|
||||
for (const item of nonCurrentItems) {
|
||||
expect(item.slots).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("no reactive methods", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
||||
import { WeatherKey, type Weather } from "./weather-context"
|
||||
import {
|
||||
DefaultWeatherKitClient,
|
||||
@@ -309,6 +310,12 @@ function createCurrentWeatherFeedItem(
|
||||
windSpeed: convertSpeed(current.windSpeed, units),
|
||||
},
|
||||
signals,
|
||||
slots: {
|
||||
insight: {
|
||||
description: currentWeatherInsightPrompt,
|
||||
content: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user