Compare commits

..

3 Commits

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

Co-authored-by: Ona <no-reply@ona.com>
2026-03-04 23:21:49 +00:00
31d5aa8d50 fix(caldav): expand recurring events in range (#55)
The iCal parser returned master VEVENT components with their
original start dates instead of expanding recurrences. Events
from months ago appeared in today's feed.

parseICalEvents now accepts an optional timeRange. When set,
recurring events are expanded via ical.js iterator and only
occurrences overlapping the range are returned. Exception
overrides (RECURRENCE-ID) are applied during expansion.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-04 23:17:14 +00:00
de29e44a08 feat(source-weatherkit): add insight slot (#54)
Add LLM-fillable insight slot to weather-current feed items.
Prompt lives in a separate .txt file for easy iteration.

Also adds interactive CLI script (scripts/query.ts) for
querying WeatherKit with credential caching and JSON output.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-03 00:00:11 +00:00
4 changed files with 223 additions and 0 deletions

View 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)
})

View File

@@ -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."

View File

@@ -176,6 +176,34 @@ 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", () => {

View File

@@ -4,6 +4,7 @@ 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,
@@ -309,6 +310,12 @@ function createCurrentWeatherFeedItem(
windSpeed: convertSpeed(current.windSpeed, units), windSpeed: convertSpeed(current.windSpeed, units),
}, },
signals, signals,
slots: {
insight: {
description: currentWeatherInsightPrompt,
content: null,
},
},
} }
} }