mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
Compare commits
1 Commits
6715f03057
...
fix/caldav
| Author | SHA1 | Date | |
|---|---|---|---|
|
a4baef521f
|
@@ -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 { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||||
import { DAVClient } from "tsdav"
|
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 { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
import { parseICalEvents } from "./ical-parser.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"
|
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
||||||
|
|
||||||
// -- Source options --
|
// -- Source options --
|
||||||
@@ -343,14 +340,6 @@ export function computeSignals(
|
|||||||
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
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 {
|
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||||
return {
|
return {
|
||||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
@@ -358,6 +347,5 @@ function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): C
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
data: event,
|
data: event,
|
||||||
signals: computeSignals(event, now, timeZone),
|
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
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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,34 +176,6 @@ describe("WeatherSource", () => {
|
|||||||
|
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
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", () => {
|
describe("no reactive methods", () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
|||||||
import { LocationKey } from "@aris/source-location"
|
import { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
|
|
||||||
import { WeatherKey, type Weather } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import {
|
import {
|
||||||
DefaultWeatherKitClient,
|
DefaultWeatherKitClient,
|
||||||
@@ -310,12 +309,6 @@ function createCurrentWeatherFeedItem(
|
|||||||
windSpeed: convertSpeed(current.windSpeed, units),
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
},
|
},
|
||||||
signals,
|
signals,
|
||||||
slots: {
|
|
||||||
insight: {
|
|
||||||
description: currentWeatherInsightPrompt,
|
|
||||||
content: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user