Compare commits

..

1 Commits

Author SHA1 Message Date
5988fd5b81 fix: remove apple calendar data source
The CalDAV-based approach doesn't work as expected for
Apple Calendar integration.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:22:13 +00:00
58 changed files with 272 additions and 4553 deletions

View File

@@ -1,43 +0,0 @@
---
name: gpg-commit-signing
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
---
# GPG Commit Signing
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
## Workflow
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
```bash
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
```
If not set, skip signing — commit without `-S`.
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
```bash
git commit -S -m "message"
```
If this succeeds, no further steps are needed.
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
```bash
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
chmod +x /tmp/gpg-sign.sh
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
rm /tmp/gpg-sign.sh
```
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
## Anti-patterns
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
- Do not leave wrapper scripts on disk after committing.

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
async fetchContext(): Promise<Partial<Context> | null> {
return null
},
async fetchItems() {

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
async fetchContext(): Promise<Partial<Context> | null> {
return null
},
async fetchItems() {

View File

@@ -89,26 +89,6 @@
"arktype": "^2.1.0",
},
},
"packages/aris-feed-enhancers": {
"name": "@aris/feed-enhancers",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
},
},
"packages/aris-source-caldav": {
"name": "@aris/source-caldav",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7",
},
},
"packages/aris-source-google-calendar": {
"name": "@aris/source-google-calendar",
"version": "0.0.0",
@@ -155,10 +135,6 @@
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
"@aris/feed-enhancers": ["@aris/feed-enhancers@workspace:packages/aris-feed-enhancers"],
"@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
@@ -923,8 +899,6 @@
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
@@ -1037,7 +1011,7 @@
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1415,8 +1389,6 @@
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.0", "", {}, "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg=="],
@@ -2191,8 +2163,6 @@
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"tsdav": ["tsdav@2.1.8", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g=="],
"tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
@@ -2315,8 +2285,6 @@
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
"xmlbuilder": ["xmlbuilder@14.0.0", "", {}, "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg=="],
@@ -2645,8 +2613,6 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fbjs/cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
"figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -2973,8 +2939,6 @@
"expo/@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"fbjs/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],

View File

@@ -1,10 +1,8 @@
import type { ContextEntry } from "./context"
import type { Context } from "./context"
import type { ContextProvider } from "./context-provider"
import { contextKey } from "./context"
interface ContextUpdatable {
pushContextUpdate(entries: readonly ContextEntry[]): void
pushContextUpdate(update: Partial<Context>): void
}
export interface ProviderError {
@@ -56,7 +54,7 @@ export class ContextBridge {
this.providers.set(provider.key, provider as ContextProvider)
const cleanup = provider.onUpdate((value) => {
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
this.controller.pushContextUpdate({ [provider.key]: value })
})
this.cleanups.push(cleanup)
@@ -69,7 +67,7 @@ export class ContextBridge {
* Returns errors from providers that failed to fetch.
*/
async refresh(): Promise<RefreshResult> {
const collected: ContextEntry[] = []
const updates: Partial<Context> = {}
const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries())
@@ -80,7 +78,7 @@ export class ContextBridge {
entries.forEach(([key], i) => {
const result = results[i]
if (result?.status === "fulfilled") {
collected.push([contextKey(key), result.value])
updates[key] = result.value
} else if (result?.status === "rejected") {
errors.push({
key,
@@ -89,7 +87,7 @@ export class ContextBridge {
}
})
this.controller.pushContextUpdate(collected)
this.controller.pushContextUpdate(updates)
return { errors }
}

View File

@@ -1,184 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ContextKey } from "./context"
import { Context, contextKey } from "./context"
interface Weather {
temperature: number
}
interface NextEvent {
title: string
}
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
describe("Context", () => {
describe("get", () => {
test("returns undefined for missing key", () => {
const ctx = new Context()
expect(ctx.get(WeatherKey)).toBeUndefined()
})
test("returns value for exact key match", () => {
const ctx = new Context()
const weather: Weather = { temperature: 20 }
ctx.set([[WeatherKey, weather]])
expect(ctx.get(WeatherKey)).toEqual(weather)
})
test("distinguishes keys with different parts", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
})
test("last write wins for same key", () => {
const ctx = new Context()
ctx.set([[WeatherKey, { temperature: 20 }]])
ctx.set([[WeatherKey, { temperature: 25 }]])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
})
})
describe("find", () => {
test("returns empty array when no keys match", () => {
const ctx = new Context()
expect(ctx.find(WeatherKey)).toEqual([])
})
test("returns exact match as single result", () => {
const ctx = new Context()
ctx.set([[NextEventKey, { title: "Standup" }]])
const results = ctx.find(NextEventKey)
expect(results).toHaveLength(1)
expect(results[0]!.value).toEqual({ title: "Standup" })
})
test("prefix match returns multiple instances", () => {
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "personal",
})
const ctx = new Context()
ctx.set([
[workKey, { title: "Sprint Planning" }],
[personalKey, { title: "Dentist" }],
])
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const results = ctx.find(prefix)
expect(results).toHaveLength(2)
const titles = results.map((r) => r.value.title).sort()
expect(titles).toEqual(["Dentist", "Sprint Planning"])
})
test("prefix match includes exact match and longer keys", () => {
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const ctx = new Context()
ctx.set([
[baseKey, { title: "Base" }],
[instanceKey, { title: "Instance" }],
])
const results = ctx.find(baseKey)
expect(results).toHaveLength(2)
})
test("does not match keys that share a string prefix but differ at segment boundary", () => {
const keyA = contextKey<string>("aris.calendar", "next")
const keyB = contextKey<string>("aris.calendar", "nextEvent")
const ctx = new Context()
ctx.set([
[keyA, "a"],
[keyB, "b"],
])
const results = ctx.find(keyA)
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("a")
})
test("object key parts with different property order match", () => {
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
const ctx = new Context()
ctx.set([[key1, "value"]])
// Exact match via get should work regardless of property order
expect(ctx.get(key2)).toBe("value")
// find with the reordered key as prefix should also match
const prefix = contextKey<string>("source", "ctx")
const results = ctx.find(prefix)
expect(results).toHaveLength(1)
})
test("single-segment prefix matches all keys starting with that segment", () => {
const ctx = new Context()
ctx.set([
[contextKey("aris.weather", "current"), { temperature: 20 }],
[contextKey("aris.weather", "forecast"), { high: 25 }],
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
])
const results = ctx.find(contextKey("aris.weather"))
expect(results).toHaveLength(2)
})
test("does not match shorter keys", () => {
const ctx = new Context()
ctx.set([[contextKey("aris.weather"), "short"]])
const results = ctx.find(contextKey("aris.weather", "current"))
expect(results).toHaveLength(0)
})
test("numeric key parts match correctly", () => {
const ctx = new Context()
ctx.set([
[contextKey("source", 1, "data"), "one"],
[contextKey("source", 2, "data"), "two"],
])
const results = ctx.find(contextKey("source", 1))
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("one")
})
})
describe("size", () => {
test("returns 0 for empty context", () => {
expect(new Context().size).toBe(0)
})
test("reflects number of entries", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.size).toBe(2)
})
})
})

View File

@@ -1,131 +1,46 @@
/**
* Tuple-keyed context system inspired by React Query's query keys.
* Branded type for type-safe context keys.
*
* Context keys are arrays that form a hierarchy. Sources write to specific
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
* and consumers can query by exact match or prefix match to get all values
* of a given type across source instances.
* Each package defines its own keys with associated value types:
* ```ts
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
*/
// -- Key types --
/** A single segment of a context key: string, number, or a record of primitives. */
export type ContextKeyPart = string | number | Record<string, unknown>
/** A context key is a readonly tuple of parts, branded with the value type. */
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
/** Creates a typed context key. */
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
return parts as ContextKey<T>
}
// -- Serialization --
export type ContextKey<T> = string & { __contextValue?: T }
/**
* Deterministic serialization of a context key for use as a Map key.
* Object parts have their keys sorted for stable comparison.
*/
export function serializeKey(key: readonly ContextKeyPart[]): string {
return JSON.stringify(key, (_key, value) => {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(value).sort()) {
sorted[k] = value[k]
}
return sorted
}
return value
})
}
// -- Key matching --
/** Returns true if `key` starts with all parts of `prefix`. */
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
if (key.length < prefix.length) return false
for (let i = 0; i < prefix.length; i++) {
if (!partsEqual(key[i]!, prefix[i]!)) return false
}
return true
}
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
function partsEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== typeof b) return false
if (a && b && typeof a === "object" && typeof b === "object") {
const aKeys = Object.keys(a)
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],
),
)
}
return false
}
// -- Context store --
/** A single context entry: a key-value pair. */
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
/**
* Mutable context store with tuple keys.
* Creates a typed context key.
*
* Supports exact-match lookups and prefix-match queries.
* Sources write context in topological order during refresh.
* @example
* ```ts
* interface Location { lat: number; lng: number; accuracy: number }
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
*/
export class Context {
export function contextKey<T>(key: string): ContextKey<T> {
return key as ContextKey<T>
}
/**
* Type-safe accessor for context values.
*
* @example
* ```ts
* const location = contextValue(context, LocationKey)
* if (location) {
* console.log(location.lat, location.lng)
* }
* ```
*/
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
return context[key] as T | undefined
}
/**
* Arbitrary key-value bag representing the current state.
* Always includes `time`. Other keys are added by context providers.
*/
export interface Context {
time: Date
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
constructor(time: Date = new Date()) {
this.time = time
this.store = new Map()
}
/** Merges entries into this context. */
set(entries: readonly ContextEntry[]): void {
for (const [key, value] of entries) {
this.store.set(serializeKey(key), { key, value })
}
}
/** Exact-match lookup. Returns the value for the given key, or undefined. */
get<T>(key: ContextKey<T>): T | undefined {
const entry = this.store.get(serializeKey(key))
return entry?.value as T | undefined
}
/**
* Prefix-match query. Returns all entries whose key starts with the given prefix.
*
* @example
* ```ts
* // Get all "nextEvent" values across calendar source instances
* const events = context.find(contextKey("nextEvent"))
* ```
*/
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
for (const entry of this.store.values()) {
if (keyStartsWith(entry.key, prefix)) {
results.push({ key: entry.key, value: entry.value as T })
}
}
return results
}
/** Returns the number of entries (excluding time). */
get size(): number {
return this.store.size
}
[key: string]: unknown
}

View File

@@ -12,7 +12,7 @@ import type { FeedItem } from "./feed"
* readonly type = "weather"
*
* async query(context: Context): Promise<WeatherItem[]> {
* const location = context.get(LocationKey)
* const location = contextValue(context, LocationKey)
* if (!location) return []
* const data = await fetchWeather(location)
* return [{

View File

@@ -1,9 +1,8 @@
import type { ContextEntry } from "./context"
import type { Context } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
import type { ReconcileResult } from "./reconciler"
import { Context } from "./context"
import { Reconciler } from "./reconciler"
export interface FeedControllerConfig {
@@ -41,7 +40,7 @@ const DEFAULT_DEBOUNCE_MS = 100
* })
*
* // Context update triggers debounced reconcile
* controller.pushContextUpdate([[LocationKey, location]])
* controller.pushContextUpdate({ [LocationKey]: location })
*
* // Direct reconcile (no debounce)
* const result = await controller.reconcile()
@@ -60,7 +59,7 @@ export class FeedController<TItems extends FeedItem = never> {
private stopped = false
constructor(config?: FeedControllerConfig) {
this.context = config?.initialContext ?? new Context()
this.context = config?.initialContext ?? { time: new Date() }
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
this.timeout = config?.timeout
}
@@ -95,10 +94,9 @@ export class FeedController<TItems extends FeedItem = never> {
}
}
/** Merges entries into context and schedules a debounced reconcile. */
pushContextUpdate(entries: readonly ContextEntry[]): void {
this.context.time = new Date()
this.context.set(entries)
/** Merges update into context and schedules a debounced reconcile. */
pushContextUpdate(update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
this.scheduleReconcile()
}

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
},
async fetchContext() {
return [[LocationKey, currentLocation]]
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.([[LocationKey, location]])
callback?.({ [LocationKey]: location })
},
}
}
@@ -85,15 +85,15 @@ function createWeatherSource(
...noActions,
async fetchContext(context) {
const location = context.get(LocationKey)
const location = contextValue(context, LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return [[WeatherKey, weather]]
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = context.get(WeatherKey)
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
},
async fetchItems(context) {
const weather = context.get(WeatherKey)
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext() {
order.push("location")
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = ctx.get(LocationKey)
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
const { context } = await engine.refresh()
expect(context.get(LocationKey)).toEqual({
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(context.get(WeatherKey)).toEqual({
expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
const { context, items } = await engine.refresh()
expect(context.get(WeatherKey)).toBeUndefined()
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
await engine.refresh()
const context = engine.currentContext()
expect(context.get(LocationKey)).toEqual({
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
})
test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
let itemUpdateCallback: (() => void) | null = null
const source: FeedSource = {
id: "reactive-items",
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
engine.start()
// Trigger item update
itemUpdateCallback!([])
itemUpdateCallback!()
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext(ctx) {
fetchCount++
const loc = ctx.get(LocationKey)
const loc = contextValue(ctx, LocationKey)
if (!loc) return null
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
async fetchItems(ctx) {
const weather = ctx.get(WeatherKey)
const weather = contextValue(ctx, WeatherKey)
if (!weather) return []
return [
{

View File

@@ -1,11 +1,8 @@
import type { ActionDefinition } from "./action"
import type { ContextEntry } from "./context"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source"
import { Context } from "./context"
export interface SourceError {
sourceId: string
error: Error
@@ -15,8 +12,6 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
context: Context
items: TItem[]
errors: SourceError[]
/** Item groups produced by post-processors */
groupedItems?: ItemGroup[]
}
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
@@ -67,11 +62,10 @@ interface SourceGraph {
export class FeedEngine<TItems extends FeedItem = FeedItem> {
private sources = new Map<string, FeedSource>()
private graph: SourceGraph | null = null
private context: Context = new Context()
private context: Context = { time: new Date() }
private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = []
private started = false
private postProcessors: FeedPostProcessor[] = []
private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null
@@ -114,23 +108,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this
}
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
*/
registerPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors.push(processor)
return this
}
/**
* Unregisters a post-processor by reference.
*/
unregisterPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
return this
}
/**
* Refreshes the feed by running all sources in dependency order.
* Calls fetchContext() then fetchItems() on each source.
@@ -140,14 +117,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const errors: SourceError[] = []
// Reset context with fresh time
const context = new Context()
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
try {
const entries = await source.fetchContext(context)
if (entries) {
context.set(entries)
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
} catch (err) {
errors.push({
@@ -175,18 +152,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context
const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], context, errors)
const result: FeedResult<TItems> = {
context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
}
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
this.updateCache(result)
return result
@@ -215,8 +181,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const source of graph.sorted) {
if (source.onContextUpdate) {
const cleanup = source.onContextUpdate(
(entries) => {
this.handleContextUpdate(source.id, entries)
(update) => {
this.handleContextUpdate(source.id, update)
},
() => this.context,
)
@@ -294,72 +260,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return actions
}
private async applyPostProcessors(
items: TItems[],
context: Context,
errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items
const allGroupedItems: ItemGroup[] = []
const allErrors = [...errors]
const boostScores = new Map<string, number>()
for (const processor of this.postProcessors) {
const snapshot = currentItems
try {
const enhancement = await processor(currentItems, context)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
// Additional items are merged untyped — this is intentional. The
// processor contract is "FeedItem in, FeedItem out"; type narrowing
// is the caller's responsibility when consuming FeedResult.
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
}
if (enhancement.suppress?.length) {
const suppressSet = new Set(enhancement.suppress)
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
}
if (enhancement.groupedItems?.length) {
allGroupedItems.push(...enhancement.groupedItems)
}
if (enhancement.boost) {
for (const [id, score] of Object.entries(enhancement.boost)) {
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
}
}
} catch (err) {
const sourceId = processor.name || "anonymous"
allErrors.push({
sourceId,
error: err instanceof Error ? err : new Error(String(err)),
})
currentItems = snapshot
}
}
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
// Stable sort within each tier preserves original relative order.
if (boostScores.size > 0) {
currentItems = applyBoostOrder(currentItems, boostScores)
}
// Remove stale item IDs from groups and drop empty groups
const itemIds = new Set(currentItems.map((item) => item.id))
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
const ids = group.itemIds.filter((id) => itemIds.has(id))
if (ids.length > 0) {
acc.push({ ...group, itemIds: ids })
}
return acc
}, [])
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
}
private ensureGraph(): SourceGraph {
if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values()))
@@ -367,9 +267,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this.graph
}
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
this.context.time = new Date()
this.context.set(entries)
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
// Re-run dependents and notify
this.refreshDependents(sourceId)
@@ -384,9 +283,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const source = graph.sources.get(id)
if (source) {
try {
const entries = await source.fetchContext(this.context)
if (entries) {
this.context.set(entries)
const update = await source.fetchContext(this.context)
if (update) {
this.context = { ...this.context, ...update }
}
} catch {
// Errors during reactive updates are logged but don't stop propagation
@@ -412,17 +311,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
const result: FeedResult<TItems> = {
context: this.context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
items: items as TItems[],
errors,
}
this.updateCache(result)
@@ -503,47 +395,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
const positive: T[] = []
const neutral: T[] = []
const negative: T[] = []
for (const item of items) {
const raw = boostScores.get(item.id)
if (raw === undefined || raw === 0) {
neutral.push(item)
} else {
const clamped = clamp(raw, -1, 1)
if (clamped > 0) {
positive.push(item)
} else if (clamped < 0) {
negative.push(item)
} else {
neutral.push(item)
}
}
}
// Sort positive descending by boost, negative descending (least negative first, most negative last)
positive.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
negative.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
return [...positive, ...neutral, ...negative]
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {

View File

@@ -1,602 +0,0 @@
import { describe, expect, mock, test } from "bun:test"
import type {
ActionDefinition,
ContextEntry,
FeedItem,
FeedPostProcessor,
FeedSource,
} from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherItem = FeedItem<"weather", { temp: number }>
type CalendarItem = FeedItem<"calendar", { title: string }>
function weatherItem(id: string, temp: number): WeatherItem {
return { id, type: "weather", timestamp: new Date(), data: { temp } }
}
function calendarItem(id: string, title: string): CalendarItem {
return { id, type: "calendar", timestamp: new Date(), data: { title } }
}
// =============================================================================
// TEST SOURCES
// =============================================================================
function createWeatherSource(items: WeatherItem[]) {
return {
id: "aris.weather",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<WeatherItem[]> {
return items
},
}
}
function createCalendarSource(items: CalendarItem[]) {
return {
id: "aris.calendar",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<CalendarItem[]> {
return items
},
}
}
// =============================================================================
// REGISTRATION
// =============================================================================
describe("FeedPostProcessor", () => {
describe("registration", () => {
test("registerPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.registerPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregisterPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.unregisterPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregistered processor does not run", async () => {
const processor = mock(async () => ({}))
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(processor)
.unregisterPostProcessor(processor)
await engine.refresh()
expect(processor).not.toHaveBeenCalled()
})
})
// =============================================================================
// ADDITIONAL ITEMS
// =============================================================================
describe("additionalItems", () => {
test("injects additional items into the feed", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
})
})
// =============================================================================
// SUPPRESS
// =============================================================================
describe("suppress", () => {
test("removes suppressed items from the feed", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("suppressing nonexistent ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
})
})
// =============================================================================
// GROUPED ITEMS
// =============================================================================
describe("groupedItems", () => {
test("accumulates grouped items on FeedResult", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
})
test("multiple processors accumulate groups", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
}))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([
{ itemIds: ["c1"], summary: "Group A" },
{ itemIds: ["c2"], summary: "Group B" },
])
})
test("stale item IDs are removed from groups after suppression", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
})
test("groups with all items suppressed are dropped", async () => {
const engine = new FeedEngine()
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
test("groupedItems is omitted when no processors produce groups", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({}))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// BOOST
// =============================================================================
describe("boost", () => {
test("positive boost moves item before non-boosted items", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w2: 0.8 } }))
const result = await engine.refresh()
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
})
test("negative boost moves item after non-boosted items", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: -0.5 } }))
const result = await engine.refresh()
expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"])
})
test("multiple boosted items are sorted by boost descending", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w3: 0.3, w1: 0.9 },
}))
const result = await engine.refresh()
// w1 (0.9) first, w3 (0.3) second, w2 (no boost) last
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
})
test("multiple processors accumulate boost scores", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: 0.3 } }))
.registerPostProcessor(async () => ({ boost: { w1: 0.4 } }))
const result = await engine.refresh()
// w1 accumulated boost = 0.7, moves before w2
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
})
test("accumulated boost is clamped to [-1, 1]", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } }))
.registerPostProcessor(async () => ({ boost: { w1: 0.8 } }))
const result = await engine.refresh()
// w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"])
})
test("out-of-range boost values are clamped", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ boost: { w1: 5.0 } }))
const result = await engine.refresh()
// Clamped to 1, still boosted to front
expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"])
})
test("boosting a suppressed item is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
suppress: ["w1"],
boost: { w1: 1.0 },
}))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("boosting a nonexistent item ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w1")
})
test("items with equal boost retain original relative order", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w1: 0.5, w3: 0.5 },
}))
const result = await engine.refresh()
// w1 and w3 have equal boost — original order preserved: w1 before w3
expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"])
})
test("negative boosts preserve relative order among demoted items", async () => {
const engine = new FeedEngine()
.register(
createWeatherSource([
weatherItem("w1", 20),
weatherItem("w2", 25),
weatherItem("w3", 30),
]),
)
.registerPostProcessor(async () => ({
boost: { w1: -0.3, w2: -0.3 },
}))
const result = await engine.refresh()
// w3 (neutral) first, then w1 and w2 (equal negative) in original order
expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"])
})
test("boost works alongside additionalItems and groupedItems", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [extra],
boost: { c1: 1.0 },
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// c1 boosted to front
expect(result.items[0].id).toBe("c1")
expect(result.items).toHaveLength(3)
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
// =============================================================================
// PIPELINE ORDERING
// =============================================================================
describe("pipeline ordering", () => {
test("each processor sees items as modified by the previous processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
}))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w1", "c1"])
})
test("suppression in first processor affects second processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w2"])
})
})
// =============================================================================
// ERROR HANDLING
// =============================================================================
describe("error handling", () => {
test("throwing processor is recorded in errors and pipeline continues", async () => {
const seen: string[] = []
async function failingProcessor(): Promise<never> {
throw new Error("processor failed")
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error.message).toBe("processor failed")
// Pipeline continued — observer still saw the original item
expect(seen).toEqual(["w1"])
expect(result.items).toHaveLength(1)
})
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => {
throw new Error("anon failed")
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
expect(ppError).toBeDefined()
})
test("non-Error throw is wrapped", async () => {
async function failingProcessor(): Promise<never> {
throw "string error"
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error).toBeInstanceOf(Error)
})
})
// =============================================================================
// REACTIVE PATHS
// =============================================================================
describe("reactive updates", () => {
test("post-processors run during reactive context updates", async () => {
let callCount = 0
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onContextUpdate(callback, _getContext) {
triggerUpdate = callback
return () => {
triggerUpdate = null
}
},
}
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
// Wait for initial periodic refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive context update
triggerUpdate!([])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
test("post-processors run during reactive item updates", async () => {
let callCount = 0
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onItemsUpdate(callback, _getContext) {
triggerItemsUpdate = callback
return () => {
triggerItemsUpdate = null
}
},
}
const engine = new FeedEngine().register(source).registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive items update
triggerItemsUpdate!([weatherItem("w1", 25)])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
})
// =============================================================================
// NO PROCESSORS = NO CHANGE
// =============================================================================
describe("no processors", () => {
test("engine without post-processors returns raw items unchanged", async () => {
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
const engine = new FeedEngine().register(createWeatherSource(items))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items[0].id).toBe("w1")
expect(result.items[1].id).toBe("w2")
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// COMBINED ENHANCEMENT
// =============================================================================
describe("combined enhancement", () => {
test("single processor can use all enhancement fields at once", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
suppress: ["w2"],
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// w2 suppressed, c1 injected → w1 + c1
expect(result.items).toHaveLength(2)
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
// Groups on result
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
})

View File

@@ -1,26 +0,0 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
export interface ItemGroup {
/** IDs of items to present together */
itemIds: string[]
/** Summary text for the group */
summary: string
}
export interface FeedEnhancement {
/** New items to inject into the feed */
additionalItems?: FeedItem[]
/** Groups of items to present together with a summary */
groupedItems?: ItemGroup[]
/** Item IDs to remove from the feed */
suppress?: string[]
/** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */
boost?: Record<string, number>
}
/**
* A function that transforms feed items and produces enhancement directives.
* Use named functions for meaningful error attribution.
*/
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement>

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
},
async fetchContext() {
return [[LocationKey, currentLocation]]
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.([[LocationKey, location]])
callback?.({ [LocationKey]: location })
},
}
}
@@ -84,15 +84,15 @@ function createWeatherSource(
...noActions,
async fetchContext(context) {
const location = context.get(LocationKey)
const location = contextValue(context, LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return [[WeatherKey, weather]]
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = context.get(WeatherKey)
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
},
async fetchItems(context) {
const weather = context.get(WeatherKey)
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
}
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
const context = new Context()
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
const entries = await source.fetchContext(context)
if (entries) {
context.set(entries)
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
}
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
test("source without context returns null from fetchContext", async () => {
const source = createAlertSource()
const result = await source.fetchContext(new Context())
const result = await source.fetchContext({ time: new Date() })
expect(result).toBeNull()
})
})
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
...noActions,
async fetchContext() {
order.push("location")
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
@@ -379,9 +379,9 @@ describe("FeedSource", () => {
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = ctx.get(LocationKey)
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
@@ -400,11 +400,11 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph)
expect(context.get(LocationKey)).toEqual({
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(context.get(WeatherKey)).toEqual({
expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
@@ -447,10 +447,12 @@ describe("FeedSource", () => {
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
// Simulate no location available
return null
},
}
@@ -460,7 +462,7 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph)
expect(context.get(WeatherKey)).toBeUndefined()
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
})
@@ -474,7 +476,7 @@ describe("FeedSource", () => {
() => {
updateCount++
},
() => new Context(),
() => ({ time: new Date() }),
)
location.simulateUpdate({ lat: 1, lng: 1 })

View File

@@ -1,5 +1,5 @@
import type { ActionDefinition } from "./action"
import type { Context, ContextEntry } from "./context"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Maps to: source/contextUpdated (notification, source → host)
*/
onContextUpdate?(
callback: (entries: readonly ContextEntry[]) => void,
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Return null if this source cannot provide context.
* Maps to: source/fetchContext
*/
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
fetchContext(context: Context): Promise<Partial<Context> | null>
/**
* Subscribe to reactive feed item updates.

View File

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

View File

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

View File

@@ -1,21 +1,18 @@
// Context
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
export { Context, contextKey, serializeKey } from "./context"
export type { Context, ContextKey } from "./context"
export { contextKey, contextValue } from "./context"
// Actions
export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
export type { FeedItem, FeedItemSignals } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"

View File

@@ -1,6 +1,5 @@
import type { ContextKey } from "@aris/core"
import type { Context } from "@aris/core"
import { Context, contextKey } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
@@ -16,25 +15,14 @@ const mockCredentials = {
serviceId: "mock",
}
interface LocationData {
lat: number
lng: number
accuracy: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response,
})
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
if (location) {
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
}
return ctx
}
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
time: new Date("2026-01-17T00:00:00Z"),
location: location ? { ...location, accuracy: 10 } : undefined,
})
describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => {
@@ -51,7 +39,7 @@ describe("WeatherKitDataSource", () => {
credentials: mockCredentials,
})
expect(dataSource.type).toBe(WeatherFeedItemType.Current)
expect(dataSource.type).toBe(WeatherFeedItemType.current)
})
test("throws error if neither client nor credentials provided", () => {
@@ -142,9 +130,9 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context)
expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
})
test("applies hourly and daily limits", async () => {
@@ -157,8 +145,8 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2)
@@ -188,8 +176,8 @@ describe("query() with mocked client", () => {
units: Units.imperial,
})
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
expect(metricCurrent).toBeDefined()
expect(imperialCurrent).toBeDefined()
@@ -215,7 +203,7 @@ describe("query() with mocked client", () => {
expect(item.signals!.timeRelevance).toBeDefined()
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
})

View File

@@ -1,6 +1,6 @@
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
import { TimeRelevance, contextKey } from "@aris/core"
import { TimeRelevance } from "@aris/core"
import {
WeatherFeedItemType,
@@ -40,18 +40,11 @@ export interface WeatherKitQueryConfig {
units?: Units
}
interface LocationData {
lat: number
lng: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.Current
readonly type = WeatherFeedItemType.current
private readonly client: WeatherKitClient
private readonly hourlyLimit: number
private readonly dailyLimit: number
@@ -66,8 +59,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
}
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey)
if (!location) {
if (!context.location) {
return []
}
@@ -75,8 +67,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
const timestamp = context.time
const response = await this.client.fetch({
lat: location.lat,
lng: location.lng,
lat: context.location.lat,
lng: context.location.lng,
})
const items: WeatherFeedItem[] = []
@@ -236,7 +228,7 @@ function createCurrentWeatherFeedItem(
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current,
type: WeatherFeedItemType.current,
timestamp,
data: {
conditionCode: current.conditionCode,
@@ -270,7 +262,7 @@ function createHourlyWeatherFeedItem(
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly,
type: WeatherFeedItemType.hourly,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
@@ -304,7 +296,7 @@ function createDailyWeatherFeedItem(
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily,
type: WeatherFeedItemType.daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
@@ -331,7 +323,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
return {
id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert,
type: WeatherFeedItemType.alert,
timestamp,
data: {
alertId: alert.id,

View File

@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = {
Current: "weather-current",
Hourly: "weather-hourly",
Daily: "weather-daily",
Alert: "weather-alert",
current: "weather-current",
hourly: "weather-hourly",
daily: "weather-daily",
alert: "weather-alert",
} as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
}
export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Current,
typeof WeatherFeedItemType.current,
CurrentWeatherData
> {}
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
}
export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly,
typeof WeatherFeedItemType.hourly,
HourlyWeatherData
> {}
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily,
typeof WeatherFeedItemType.daily,
DailyWeatherData
> {}
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
}
export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.Alert,
typeof WeatherFeedItemType.alert,
WeatherAlertData
> {}

View File

@@ -1,17 +0,0 @@
{
"name": "@aris/feed-enhancers",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test src/"
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-caldav": "workspace:*",
"@aris/source-google-calendar": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*"
}
}

View File

@@ -1 +0,0 @@
export { createTimeOfDayEnhancer, type TimeOfDayEnhancerOptions } from "./time-of-day-enhancer.ts"

View File

@@ -1,704 +0,0 @@
import type { FeedItem, FeedItemSignals } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core"
import { CalDavFeedItemType } from "@aris/source-caldav"
import { CalendarFeedItemType } from "@aris/source-google-calendar"
import { TflFeedItemType } from "@aris/source-tfl"
import { WeatherFeedItemType } from "@aris/source-weatherkit"
import { describe, expect, test } from "bun:test"
import {
createTimeOfDayEnhancer,
getTimePeriod,
getDayType,
TimePeriod,
DayType,
} from "./time-of-day-enhancer"
// =============================================================================
// Helpers
// =============================================================================
function makeContext(date: Date): Context {
return new Context(date)
}
function makeDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
return new Date(year, month - 1, day, hour, minute, 0, 0)
}
/** Tuesday 2025-07-08 at given hour:minute */
function tuesday(hour: number, minute = 0): Date {
return makeDate(2025, 7, 8, hour, minute)
}
/** Saturday 2025-07-12 at given hour:minute */
function saturday(hour: number, minute = 0): Date {
return makeDate(2025, 7, 12, hour, minute)
}
function weatherCurrent(id = "w-current"): FeedItem {
return {
id,
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: 18, precipitationIntensity: 0 },
}
}
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
return {
id,
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: 12, precipitationIntensity: 2.5 },
}
}
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
return {
id,
type: WeatherFeedItemType.Current,
timestamp: new Date(),
data: { temperature: -5, precipitationIntensity: 0 },
}
}
function weatherHourly(id = "w-hourly"): FeedItem {
return {
id,
type: WeatherFeedItemType.Hourly,
timestamp: new Date(),
data: { forecastTime: new Date(), temperature: 20 },
}
}
function weatherDaily(id = "w-daily"): FeedItem {
return {
id,
type: WeatherFeedItemType.Daily,
timestamp: new Date(),
data: { forecastDate: new Date() },
}
}
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
return {
id,
type: WeatherFeedItemType.Alert,
timestamp: new Date(),
data: { severity: "extreme" },
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
}
}
function calendarEvent(
id: string,
startTime: Date,
options: { location?: string; signals?: FeedItemSignals } = {},
): FeedItem {
return {
id,
type: CalendarFeedItemType.Event,
timestamp: new Date(),
data: {
eventId: id,
calendarId: "primary",
title: `Event ${id}`,
description: null,
location: options.location ?? null,
startTime,
endTime: new Date(startTime.getTime() + 3_600_000),
isAllDay: false,
status: "confirmed",
htmlLink: "",
},
signals: options.signals,
}
}
function calendarAllDay(id: string): FeedItem {
return {
id,
type: CalendarFeedItemType.AllDay,
timestamp: new Date(),
data: {
eventId: id,
calendarId: "primary",
title: `All Day ${id}`,
description: null,
location: null,
startTime: new Date(),
endTime: new Date(),
isAllDay: true,
status: "confirmed",
htmlLink: "",
},
signals: { timeRelevance: TimeRelevance.Ambient },
}
}
function caldavEvent(
id: string,
startDate: Date,
options: { location?: string; signals?: FeedItemSignals } = {},
): FeedItem {
return {
id,
type: CalDavFeedItemType.Event,
timestamp: new Date(),
data: {
uid: id,
title: `CalDAV ${id}`,
startDate,
endDate: new Date(startDate.getTime() + 3_600_000),
isAllDay: false,
location: options.location ?? null,
description: null,
calendarName: null,
status: "confirmed",
url: null,
organizer: null,
attendees: [],
alarms: [],
recurrenceId: null,
},
signals: options.signals,
}
}
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
return {
id,
type: TflFeedItemType.Alert,
timestamp: new Date(),
data: {
line: "northern",
lineName: "Northern",
severity: "major-delays",
description: "Delays",
},
signals: { urgency, timeRelevance: TimeRelevance.Imminent },
}
}
function unknownItem(id = "unknown-1"): FeedItem {
return {
id,
type: "some-future-type",
timestamp: new Date(),
data: { foo: "bar" },
}
}
// =============================================================================
// Period detection
// =============================================================================
describe("getTimePeriod", () => {
test("morning: 06:0011:59", () => {
expect(getTimePeriod(tuesday(6))).toBe(TimePeriod.Morning)
expect(getTimePeriod(tuesday(8))).toBe(TimePeriod.Morning)
expect(getTimePeriod(tuesday(11, 59))).toBe(TimePeriod.Morning)
})
test("afternoon: 12:0016:59", () => {
expect(getTimePeriod(tuesday(12))).toBe(TimePeriod.Afternoon)
expect(getTimePeriod(tuesday(14))).toBe(TimePeriod.Afternoon)
expect(getTimePeriod(tuesday(16, 59))).toBe(TimePeriod.Afternoon)
})
test("evening: 17:0021:59", () => {
expect(getTimePeriod(tuesday(17))).toBe(TimePeriod.Evening)
expect(getTimePeriod(tuesday(19))).toBe(TimePeriod.Evening)
expect(getTimePeriod(tuesday(21, 59))).toBe(TimePeriod.Evening)
})
test("night: 22:0005:59", () => {
expect(getTimePeriod(tuesday(22))).toBe(TimePeriod.Night)
expect(getTimePeriod(tuesday(0))).toBe(TimePeriod.Night)
expect(getTimePeriod(tuesday(3))).toBe(TimePeriod.Night)
expect(getTimePeriod(tuesday(5, 59))).toBe(TimePeriod.Night)
})
})
describe("getDayType", () => {
test("weekday: MondayFriday", () => {
// 2025-07-07 is Monday, 2025-07-08 is Tuesday, 2025-07-11 is Friday
expect(getDayType(makeDate(2025, 7, 7, 10))).toBe(DayType.Weekday)
expect(getDayType(tuesday(10))).toBe(DayType.Weekday)
expect(getDayType(makeDate(2025, 7, 11, 10))).toBe(DayType.Weekday)
})
test("weekend: SaturdaySunday", () => {
expect(getDayType(saturday(10))).toBe(DayType.Weekend)
expect(getDayType(makeDate(2025, 7, 13, 10))).toBe(DayType.Weekend) // Sunday
})
})
// =============================================================================
// Morning
// =============================================================================
describe("morning weekday", () => {
const now = tuesday(8)
const ctx = makeContext(now)
test("boosts weather-current and weather-alert, demotes weather-hourly", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherCurrent(), weatherHourly(), weatherAlert()]
const result = await enhancer(items, ctx)
expect(result.boost!["w-current"]).toBeGreaterThan(0)
expect(result.boost!["w-alert"]).toBeGreaterThan(0)
expect(result.boost!["w-hourly"]).toBeLessThan(0)
})
test("boosts first calendar event of the day", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event1 = calendarEvent("c1", tuesday(9))
const event2 = calendarEvent("c2", tuesday(14))
const result = await enhancer([event1, event2], ctx)
expect(result.boost!["c1"]).toBeGreaterThan(0)
// Second event should not get the first-event boost
expect(result.boost?.["c2"] ?? 0).toBeLessThanOrEqual(0)
})
test("boosts TfL alerts", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const result = await enhancer([tflAlert()], ctx)
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
})
})
describe("morning weekend", () => {
const now = saturday(9)
const ctx = makeContext(now)
test("boosts weather-current and weather-daily", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherCurrent(), weatherDaily()]
const result = await enhancer(items, ctx)
expect(result.boost!["w-current"]).toBeGreaterThan(0)
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
})
test("demotes calendar events and TfL alerts", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", saturday(10))
const items = [event, tflAlert()]
const result = await enhancer(items, ctx)
expect(result.boost!["c1"]).toBeLessThan(0)
expect(result.boost!["tfl-1"]).toBeLessThan(0)
})
})
// =============================================================================
// Afternoon
// =============================================================================
describe("afternoon weekday", () => {
const now = tuesday(14)
const ctx = makeContext(now)
test("boosts imminent calendar events", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", tuesday(14, 10), {
signals: { timeRelevance: TimeRelevance.Imminent },
})
const result = await enhancer([event], ctx)
expect(result.boost!["c1"]).toBeGreaterThan(0)
})
test("demotes weather-current and weather-hourly", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherCurrent(), weatherHourly()]
const result = await enhancer(items, ctx)
expect(result.boost!["w-current"]).toBeLessThan(0)
expect(result.boost!["w-hourly"]).toBeLessThan(0)
})
})
describe("afternoon weekend", () => {
const now = saturday(14)
const ctx = makeContext(now)
test("boosts weather-current, demotes calendar events", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", saturday(15))
const items = [weatherCurrent(), event]
const result = await enhancer(items, ctx)
expect(result.boost!["w-current"]).toBeGreaterThan(0)
expect(result.boost!["c1"]).toBeLessThan(0)
})
})
// =============================================================================
// Evening
// =============================================================================
describe("evening weekday", () => {
const now = tuesday(19)
const ctx = makeContext(now)
test("suppresses ambient work calendar events", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", tuesday(9), {
signals: { timeRelevance: TimeRelevance.Ambient },
})
const result = await enhancer([event], ctx)
expect(result.suppress).toContain("c1")
})
test("demotes TfL alerts", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const result = await enhancer([tflAlert()], ctx)
expect(result.boost!["tfl-1"]).toBeLessThan(0)
})
test("boosts weather-daily and all-day calendar events", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherDaily(), calendarAllDay("ad1")]
const result = await enhancer(items, ctx)
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
expect(result.boost!["ad1"]).toBeGreaterThan(0)
})
})
describe("evening weekend", () => {
const now = saturday(19)
const ctx = makeContext(now)
test("boosts weather-current, suppresses ambient calendar events", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", saturday(9), {
signals: { timeRelevance: TimeRelevance.Ambient },
})
const items = [weatherCurrent(), event]
const result = await enhancer(items, ctx)
expect(result.boost!["w-current"]).toBeGreaterThan(0)
expect(result.suppress).toContain("c1")
})
test("demotes TfL alerts more aggressively", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const result = await enhancer([tflAlert()], ctx)
expect(result.boost!["tfl-1"]).toBeLessThan(-0.3)
})
})
// =============================================================================
// Night
// =============================================================================
describe("night", () => {
const now = tuesday(23)
const ctx = makeContext(now)
test("suppresses ambient items", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", tuesday(9), {
signals: { timeRelevance: TimeRelevance.Ambient },
})
const result = await enhancer([event], ctx)
expect(result.suppress).toContain("c1")
})
test("demotes calendar events and weather-current", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9)) // tomorrow
const items = [event, weatherCurrent()]
const result = await enhancer(items, ctx)
expect(result.boost!["c1"]).toBeLessThan(0)
expect(result.boost!["w-current"]).toBeLessThan(0)
})
test("high-urgency alerts survive unboosted", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const alert = weatherAlert("w-alert", 0.9)
const result = await enhancer([alert], ctx)
// Should not be demoted — either no boost entry or >= 0
const alertBoost = result.boost?.["w-alert"] ?? 0
expect(alertBoost).toBeGreaterThanOrEqual(0)
})
})
// =============================================================================
// Pre-meeting window
// =============================================================================
describe("pre-meeting window", () => {
test("boosts upcoming meeting to +0.9", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10))
const result = await enhancer([meeting], makeContext(now))
expect(result.boost!["c1"]).toBe(0.9)
})
test("suppresses low-urgency items during pre-meeting", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10))
const lowPriority = weatherHourly()
lowPriority.signals = { urgency: 0.1 }
const result = await enhancer([meeting, lowPriority], makeContext(now))
expect(result.suppress).toContain("w-hourly")
})
test("does not suppress items without signals during pre-meeting", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10))
const noSignals = weatherDaily()
const result = await enhancer([meeting, noSignals], makeContext(now))
expect(result.suppress ?? []).not.toContain("w-daily")
})
test("boosts TfL alerts during pre-meeting", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10))
const result = await enhancer([meeting, tflAlert()], makeContext(now))
expect(result.boost!["tfl-1"]).toBeGreaterThan(0)
})
test("boosts weather-current if meeting has a location", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10), { location: "Office, London" })
const result = await enhancer([meeting, weatherCurrent()], makeContext(now))
expect(result.boost!["w-current"]).toBeGreaterThan(0)
})
test("works with CalDAV events", async () => {
const now = tuesday(9, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = caldavEvent("cd1", tuesday(10))
const result = await enhancer([meeting], makeContext(now))
expect(result.boost!["cd1"]).toBe(0.9)
})
test("does not trigger for events more than 30 minutes away", async () => {
const now = tuesday(9)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(10))
const result = await enhancer([meeting], makeContext(now))
// Should not get the +0.9 pre-meeting boost
expect(result.boost?.["c1"] ?? 0).not.toBe(0.9)
})
})
// =============================================================================
// Wind-down gradient
// =============================================================================
describe("wind-down gradient", () => {
test("20:00 weekday: additional -0.1 on work items", async () => {
const now = tuesday(20)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
// Non-ambient calendar event — evening rules don't boost or suppress it,
// so the only demotion comes from wind-down at 20:00 (-0.1).
const event = calendarEvent("c1", makeDate(2025, 7, 9, 9))
const result = await enhancer([event], makeContext(now))
expect(result.boost!["c1"]).toBe(-0.1)
})
test("21:00 weekday: additional -0.2 on work items", async () => {
const now = tuesday(21)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const alert = tflAlert("tfl-1", 0.5)
const result = await enhancer([alert], makeContext(now))
// Evening demotes TfL by -0.4, wind-down adds -0.2 = -0.6
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.6)
})
test("21:30 weekday: additional -0.3 on work items", async () => {
const now = tuesday(21, 30)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const alert = tflAlert("tfl-1", 0.5)
const result = await enhancer([alert], makeContext(now))
// Evening demotes TfL by -0.4, wind-down adds -0.3 = -0.7
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(-0.7)
})
test("does not apply on weekends", async () => {
const now = saturday(21)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const alert = tflAlert("tfl-1", 0.5)
const result = await enhancer([alert], makeContext(now))
// Weekend evening demotes TfL by -0.5, but no wind-down
expect(result.boost!["tfl-1"]).toBe(-0.5)
})
})
// =============================================================================
// Transition lookahead
// =============================================================================
describe("transition lookahead", () => {
test("Saturday 11:40 boosts afternoon-relevant weather-current", async () => {
const now = saturday(11, 40)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherCurrent()]
const result = await enhancer(items, makeContext(now))
// Weekend morning boosts weather-current by +0.5.
// Transition to afternoon adds +0.2 (weekend afternoon boosts weather-current).
expect(result.boost!["w-current"]).toBe(0.7)
})
test("16:40 weekday boosts evening-relevant items (weather-daily)", async () => {
const now = tuesday(16, 40)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherDaily()]
const result = await enhancer(items, makeContext(now))
// Afternoon weekday doesn't boost weather-daily, but transition to evening does (+0.2)
expect(result.boost!["w-daily"]).toBeGreaterThan(0)
})
test("does not apply when far from boundary", async () => {
const now = tuesday(14)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const items = [weatherDaily()]
const result = await enhancer(items, makeContext(now))
// Afternoon weekday doesn't boost or demote weather-daily, and no transition
expect(result.boost?.["w-daily"]).toBeUndefined()
})
})
// =============================================================================
// Weather-time correlation
// =============================================================================
describe("weather-time correlation", () => {
test("morning weekday: extra boost for precipitation", async () => {
const now = tuesday(8)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const rainy = weatherCurrentRainy()
const dry = weatherCurrent("w-dry")
const result = await enhancer([rainy, dry], makeContext(now))
// Both get morning boost, but rainy gets extra +0.1
expect(result.boost!["w-current-rain"]).toBeGreaterThan(result.boost!["w-dry"] ?? 0)
})
test("morning weekday: extra boost for extreme temperature", async () => {
const now = tuesday(8)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const extreme = weatherCurrentExtreme()
const normal = weatherCurrent("w-normal")
const result = await enhancer([extreme, normal], makeContext(now))
expect(result.boost!["w-current-extreme"]).toBeGreaterThan(result.boost!["w-normal"] ?? 0)
})
test("evening with location event: extra boost for weather-current", async () => {
const now = tuesday(19)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const event = calendarEvent("c1", tuesday(19, 30), { location: "The Ivy, London" })
const items = [weatherCurrent(), event]
const result = await enhancer(items, makeContext(now))
// Weather-current gets evening weather-time correlation boost (+0.2)
// Note: evening weekday doesn't normally boost weather-current
expect(result.boost!["w-current"]).toBeGreaterThan(0)
})
test("weather-alert always gets at least +0.5", async () => {
const now = tuesday(14) // afternoon — no special weather boost
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const alert = weatherAlert("w-alert", 0.5)
const result = await enhancer([alert], makeContext(now))
expect(result.boost!["w-alert"]).toBeGreaterThanOrEqual(0.5)
})
})
// =============================================================================
// Edge cases
// =============================================================================
describe("edge cases", () => {
test("empty items returns empty enhancement", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
const result = await enhancer([], makeContext(tuesday(8)))
expect(result).toEqual({})
})
test("unknown item types get no boost", async () => {
const enhancer = createTimeOfDayEnhancer({ clock: () => tuesday(8) })
const result = await enhancer([unknownItem()], makeContext(tuesday(8)))
expect(result.boost?.["unknown-1"]).toBeUndefined()
expect(result.suppress).toBeUndefined()
})
test("uses context.time when no clock provided", async () => {
const enhancer = createTimeOfDayEnhancer()
const morningCtx = makeContext(tuesday(8))
const items = [weatherCurrent()]
const result = await enhancer(items, morningCtx)
// Should apply morning rules — weather-current boosted
expect(result.boost!["w-current"]).toBeGreaterThan(0)
})
test("boost values are clamped to [-1, 1]", async () => {
// Morning weekday: TfL alert gets +0.6 from period rules.
// Pre-meeting adds +0.5. Total would be +1.1 without clamping.
const now = tuesday(8, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(9))
const alert = tflAlert("tfl-1", 0.8)
const result = await enhancer([meeting, alert], makeContext(now))
expect(result.boost!["tfl-1"]).toBeLessThanOrEqual(1)
expect(result.boost!["tfl-1"]).toBeGreaterThanOrEqual(-1)
})
test("suppress list is deduplicated", async () => {
// An item that would be suppressed by both evening rules and pre-meeting low-urgency
const now = tuesday(19, 45)
const enhancer = createTimeOfDayEnhancer({ clock: () => now })
const meeting = calendarEvent("c1", tuesday(20))
const ambientEvent = calendarEvent("c2", tuesday(9), {
signals: { urgency: 0.1, timeRelevance: TimeRelevance.Ambient },
})
const result = await enhancer([meeting, ambientEvent], makeContext(now))
if (result.suppress) {
const c2Count = result.suppress.filter((id) => id === "c2").length
expect(c2Count).toBeLessThanOrEqual(1)
}
})
})

View File

@@ -1,595 +0,0 @@
import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aris/core"
import { TimeRelevance } from "@aris/core"
import type { CalDavEventData } from "@aris/source-caldav"
import type { CalendarEventData } from "@aris/source-google-calendar"
import type { CurrentWeatherData } from "@aris/source-weatherkit"
import { CalDavFeedItemType } from "@aris/source-caldav"
import { CalendarFeedItemType } from "@aris/source-google-calendar"
import { TflFeedItemType } from "@aris/source-tfl"
import { WeatherFeedItemType } from "@aris/source-weatherkit"
export const TimePeriod = {
Morning: "morning",
Afternoon: "afternoon",
Evening: "evening",
Night: "night",
} as const
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod]
export const DayType = {
Weekday: "weekday",
Weekend: "weekend",
} as const
export type DayType = (typeof DayType)[keyof typeof DayType]
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
const TRANSITION_WINDOW_MS = 30 * 60 * 1000
const PERIOD_BOUNDARIES = [
{ hour: 6, period: TimePeriod.Morning },
{ hour: 12, period: TimePeriod.Afternoon },
{ hour: 17, period: TimePeriod.Evening },
{ hour: 22, period: TimePeriod.Night },
] as const
/** All calendar event types across sources */
const CALENDAR_EVENT_TYPES: ReadonlySet<string> = new Set([
CalendarFeedItemType.Event,
CalDavFeedItemType.Event,
])
/**
* Creates a post-processor that reranks feed items based on time of day.
*
* Prioritizes items that matter right now and pushes down items that don't:
*
* - Morning: weather and first meeting rise, hourly forecasts sink.
* Weekends flip — weather stays up but work calendar and commute alerts drop.
* - Afternoon: imminent meetings rise. Stale weather sinks.
* - Evening: work calendar is suppressed, tomorrow's forecast and personal
* events rise. Weekends suppress work more aggressively.
* - Night: almost everything sinks except high-urgency alerts.
* - Pre-meeting (30 min before any event): that meeting dominates, low-urgency
* noise is suppressed, commute/weather context rises if the meeting has a location.
* - Wind-down (weekday 2022h): work items progressively sink as night approaches.
* - Transition lookahead (30 min before a period boundary): items relevant to
* the next period get a head start.
* - Weather-time correlation: precipitation boosts morning weather, evening
* events with locations boost current weather, alerts always stay high.
*/
export interface TimeOfDayEnhancerOptions {
/** Override clock for testing. Defaults to reading context.time. */
clock?: () => Date
}
export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): FeedPostProcessor {
const clock = options?.clock
function timeOfDayEnhancer(items: FeedItem[], context: Context): Promise<FeedEnhancement> {
if (items.length === 0) return Promise.resolve({})
const now = clock ? clock() : context.time
const period = getTimePeriod(now)
const dayType = getDayType(now)
const boost: Record<string, number> = {}
const suppress: string[] = []
// 1. Apply period-based rules
const firstEventId = findFirstEventOfDay(items, now)
switch (period) {
case TimePeriod.Morning:
if (dayType === DayType.Weekday) {
applyMorningWeekday(items, boost, firstEventId)
} else {
applyMorningWeekend(items, boost)
}
break
case TimePeriod.Afternoon:
if (dayType === DayType.Weekday) {
applyAfternoonWeekday(items, boost)
} else {
applyAfternoonWeekend(items, boost)
}
break
case TimePeriod.Evening:
if (dayType === DayType.Weekday) {
applyEveningWeekday(items, boost, suppress)
} else {
applyEveningWeekend(items, boost, suppress)
}
break
case TimePeriod.Night:
applyNight(items, boost, suppress)
break
}
// 2. Pre-meeting overrides (can override period rules)
const preMeeting = detectPreMeetingItems(items, now)
applyPreMeetingOverrides(items, preMeeting, boost, suppress)
// 3. Wind-down gradient
applyWindDown(items, now, dayType, boost)
// 4. Transition lookahead
applyTransitionLookahead(items, now, period, dayType, boost)
// 5. Weather-time correlation
const eveningLocation = hasEveningCalendarEventWithLocation(items, now)
applyWeatherTimeCorrelation(items, period, dayType, eveningLocation, boost)
// Clamp boost values to [-1, 1] — additive layers can exceed the range
for (const id in boost) {
boost[id] = Math.max(-1, Math.min(1, boost[id]!))
}
const result: FeedEnhancement = {}
if (Object.keys(boost).length > 0) {
result.boost = boost
}
const uniqueSuppress = [...new Set(suppress)]
if (uniqueSuppress.length > 0) {
result.suppress = uniqueSuppress
}
return Promise.resolve(result)
}
return timeOfDayEnhancer
}
export function getTimePeriod(date: Date): TimePeriod {
const hour = date.getHours()
if (hour >= 22 || hour < 6) return TimePeriod.Night
if (hour >= 17) return TimePeriod.Evening
if (hour >= 12) return TimePeriod.Afternoon
return TimePeriod.Morning
}
export function getDayType(date: Date): DayType {
const day = date.getDay()
return day === 0 || day === 6 ? DayType.Weekend : DayType.Weekday
}
/**
* Returns the next period boundary as { hour, period } and the ms until it.
*/
function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: number } {
const hour = date.getHours()
const minuteMs = date.getMinutes() * 60_000 + date.getSeconds() * 1000 + date.getMilliseconds()
for (const boundary of PERIOD_BOUNDARIES) {
if (hour < boundary.hour) {
const msUntil = (boundary.hour - hour) * 3_600_000 - minuteMs
return { period: boundary.period, msUntil }
}
}
// Past 22:00 — next boundary is morning at 06:00
const hoursUntil6 = (24 - hour + 6) * 3_600_000 - minuteMs
return { period: TimePeriod.Morning, msUntil: hoursUntil6 }
}
/**
* Extract start time from calendar event data.
* Google Calendar uses `startTime`, CalDAV uses `startDate`.
*/
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
}
/**
* Check if a current weather item indicates precipitation or extreme conditions.
* Only meaningful for weather-current items.
*/
function hasPrecipitationOrExtreme(item: FeedItem): boolean {
const data = item.data as CurrentWeatherData
if (data.precipitationIntensity > 0) return true
if (data.temperature < 0 || data.temperature > 35) return true
return false
}
interface PreMeetingInfo {
/** IDs of calendar items starting within the pre-meeting window */
upcomingMeetingIds: Set<string>
/** Whether any upcoming meeting has a location */
hasLocationMeeting: boolean
}
function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
const nowMs = now.getTime()
const upcomingMeetingIds = new Set<string>()
let hasLocationMeeting = false
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const msUntil = getEventStartTime(data).getTime() - nowMs
if (msUntil > 0 && msUntil <= PRE_MEETING_WINDOW_MS) {
upcomingMeetingIds.add(item.id)
if (data.location) {
hasLocationMeeting = true
}
}
}
return { upcomingMeetingIds, hasLocationMeeting }
}
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
let earliest: { id: string; time: number } | null = null
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const startTime = getEventStartTime(data)
const startMs = startTime.getTime()
// Must be today and in the future
const sameDay =
startTime.getFullYear() === now.getFullYear() &&
startTime.getMonth() === now.getMonth() &&
startTime.getDate() === now.getDate()
if (!sameDay) continue
if (startMs <= now.getTime()) continue
if (!earliest || startMs < earliest.time) {
earliest = { id: item.id, time: startMs }
}
}
return earliest?.id ?? null
}
function applyMorningWeekday(
items: FeedItem[],
boost: Record<string, number>,
firstEventId: string | null,
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.7
break
case WeatherFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.8
break
case WeatherFeedItemType.Hourly:
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) - 0.3
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.6
break
}
}
if (firstEventId) {
boost[firstEventId] = (boost[firstEventId] ?? 0) + 0.6
}
}
function applyMorningWeekend(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.5
break
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) + 0.4
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.4
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.3
break
}
}
}
function applyAfternoonWeekday(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Imminent) {
boost[item.id] = (boost[item.id] ?? 0) + 0.5
}
break
case WeatherFeedItemType.Current:
case WeatherFeedItemType.Hourly:
boost[item.id] = (boost[item.id] ?? 0) - 0.2
break
}
}
}
function applyAfternoonWeekend(items: FeedItem[], boost: Record<string, number>): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.2
break
}
}
}
function applyEveningWeekday(
items: FeedItem[],
boost: Record<string, number>,
suppress: string[],
): void {
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
}
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.4
break
case WeatherFeedItemType.Daily:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.AllDay:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
}
}
}
function applyEveningWeekend(
items: FeedItem[],
boost: Record<string, number>,
suppress: string[],
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Current:
boost[item.id] = (boost[item.id] ?? 0) + 0.3
break
case CalendarFeedItemType.Event:
case CalDavFeedItemType.Event:
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
}
break
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
}
}
}
function applyNight(items: FeedItem[], boost: Record<string, number>, suppress: string[]): void {
for (const item of items) {
// Suppress all ambient items
if (item.signals?.timeRelevance === TimeRelevance.Ambient) {
suppress.push(item.id)
continue
}
// High-urgency alerts survive unboosted
if (
(item.type === WeatherFeedItemType.Alert || item.type === TflFeedItemType.Alert) &&
(item.signals?.urgency ?? 0) >= 0.8
) {
continue
}
// Demote everything else
switch (item.type) {
case CalendarFeedItemType.Event:
case CalendarFeedItemType.AllDay:
case CalDavFeedItemType.Event:
boost[item.id] = (boost[item.id] ?? 0) - 0.6
break
case WeatherFeedItemType.Current:
case WeatherFeedItemType.Hourly:
boost[item.id] = (boost[item.id] ?? 0) - 0.5
break
}
}
}
function applyPreMeetingOverrides(
items: FeedItem[],
preMeeting: PreMeetingInfo,
boost: Record<string, number>,
suppress: string[],
): void {
if (preMeeting.upcomingMeetingIds.size === 0) return
// Intentional override, not additive — the upcoming meeting should dominate
// regardless of what period rules assigned. Don't reorder this before period rules.
for (const meetingId of preMeeting.upcomingMeetingIds) {
boost[meetingId] = 0.9
}
for (const item of items) {
if (preMeeting.upcomingMeetingIds.has(item.id)) continue
switch (item.type) {
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + 0.5
break
case WeatherFeedItemType.Current:
if (preMeeting.hasLocationMeeting) {
boost[item.id] = (boost[item.id] ?? 0) + 0.4
}
break
}
// Suppress items that explicitly declare low urgency.
// Items without signals are left alone — absence of urgency is not low urgency.
if (item.signals && item.signals.urgency !== undefined && item.signals.urgency < 0.3) {
suppress.push(item.id)
}
}
}
function applyWindDown(
items: FeedItem[],
now: Date,
dayType: DayType,
boost: Record<string, number>,
): void {
if (dayType !== DayType.Weekday) return
const hour = now.getHours()
const minutes = now.getMinutes()
if (hour < 20 || hour >= 22) return
// Gradient: 20:00 → -0.1, 21:00 → -0.2, 21:30+ → -0.3
let additionalDemotion: number
if (hour === 20) {
additionalDemotion = -0.1
} else if (hour === 21 && minutes < 30) {
additionalDemotion = -0.2
} else {
additionalDemotion = -0.3
}
for (const item of items) {
switch (item.type) {
case CalendarFeedItemType.Event:
case CalendarFeedItemType.AllDay:
case CalDavFeedItemType.Event:
case TflFeedItemType.Alert:
boost[item.id] = (boost[item.id] ?? 0) + additionalDemotion
break
}
}
}
function applyTransitionLookahead(
items: FeedItem[],
now: Date,
currentPeriod: TimePeriod,
dayType: DayType,
boost: Record<string, number>,
): void {
const next = getNextPeriodBoundary(now)
if (next.msUntil > TRANSITION_WINDOW_MS) return
// Apply a +0.2 secondary boost to items that would be boosted in the next period
const nextPeriodBoost = getNextPeriodBoostTargets(next.period, dayType)
for (const item of items) {
if (nextPeriodBoost.has(item.type)) {
boost[item.id] = (boost[item.id] ?? 0) + 0.2
}
}
}
/**
* Returns the set of item types that get boosted in a given period+dayType.
*/
function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): ReadonlySet<string> {
const targets = new Set<string>()
switch (period) {
case TimePeriod.Morning:
targets.add(WeatherFeedItemType.Current)
if (dayType === DayType.Weekday) {
targets.add(WeatherFeedItemType.Alert)
targets.add(TflFeedItemType.Alert)
} else {
targets.add(WeatherFeedItemType.Daily)
}
break
case TimePeriod.Afternoon:
if (dayType === DayType.Weekend) {
targets.add(WeatherFeedItemType.Current)
}
break
case TimePeriod.Evening:
targets.add(WeatherFeedItemType.Daily)
if (dayType === DayType.Weekend) {
targets.add(WeatherFeedItemType.Current)
}
break
case TimePeriod.Night:
// Night doesn't boost much — transition toward night means demoting,
// which is handled by wind-down. No positive targets here.
break
}
return targets
}
function applyWeatherTimeCorrelation(
items: FeedItem[],
period: TimePeriod,
dayType: DayType,
hasEveningEventWithLocation: boolean,
boost: Record<string, number>,
): void {
for (const item of items) {
switch (item.type) {
case WeatherFeedItemType.Alert: {
const current = boost[item.id] ?? 0
if (current < 0.5) {
boost[item.id] = 0.5
}
break
}
case WeatherFeedItemType.Current:
if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
boost[item.id] = (boost[item.id] ?? 0) + 0.1
}
if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
boost[item.id] = (boost[item.id] ?? 0) + 0.2
}
break
}
}
}
function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): boolean {
const todayEvening17 = new Date(now)
todayEvening17.setHours(17, 0, 0, 0)
const todayNight22 = new Date(now)
todayNight22.setHours(22, 0, 0, 0)
for (const item of items) {
if (!CALENDAR_EVENT_TYPES.has(item.type)) continue
const data = item.data as CalendarEventData | CalDavEventData
const startMs = getEventStartTime(data).getTime()
if (startMs >= todayEvening17.getTime() && startMs < todayNight22.getTime()) {
if (data.location) return true
}
}
return false
}

View File

@@ -1,58 +0,0 @@
# @aris/source-caldav
A FeedSource that fetches calendar events from any CalDAV server.
## Usage
```ts
import { CalDavSource } from "@aris/source-caldav"
// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
lookAheadDays: 7, // optional, default: 0 (today only)
timeZone: "America/New_York", // optional, default: UTC
})
// OAuth
const source = new CalDavSource({
serverUrl: "https://caldav.provider.com",
authMethod: "oauth",
accessToken: "...",
refreshToken: "...",
tokenUrl: "https://provider.com/oauth/token",
})
```
### iCloud
Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654):
```ts
const source = new CalDavSource({
serverUrl: "https://caldav.icloud.com",
authMethod: "basic",
username: "you@icloud.com",
password: "<app-specific-password>",
})
```
## Testing
```bash
bun test
```
### Live test
`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for:
- **CalDAV server URL** — e.g. `https://caldav.icloud.com`
- **Username** — your account email
- **Password** — your password (or app-specific password for iCloud)
- **Look-ahead days** — how many days beyond today to fetch (default: 0)
The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees.

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:all-day-001@test
DTSTART;VALUE=DATE:20260115
DTEND;VALUE=DATE:20260116
SUMMARY:Company Holiday
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:cancelled-001@test
DTSTART:20260115T120000Z
DTEND:20260115T130000Z
SUMMARY:Cancelled Meeting
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

View File

@@ -1,10 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:minimal-001@test
DTSTART:20260115T180000Z
DTEND:20260115T190000Z
SUMMARY:Quick Chat
END:VEVENT
END:VCALENDAR

View File

@@ -1,20 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:recurring-001@test
DTSTART:20260115T090000Z
DTEND:20260115T093000Z
SUMMARY:Weekly Sync
RRULE:FREQ=WEEKLY;COUNT=4
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:recurring-001@test
RECURRENCE-ID:20260122T090000Z
DTSTART:20260122T100000Z
DTEND:20260122T103000Z
SUMMARY:Weekly Sync (moved)
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -1,26 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:single-event-001@test
DTSTART:20260115T140000Z
DTEND:20260115T150000Z
SUMMARY:Team Standup
LOCATION:Conference Room A
DESCRIPTION:Daily standup meeting
STATUS:CONFIRMED
URL:https://example.com/meeting/123
ORGANIZER;CN=Alice Smith:mailto:alice@example.com
ATTENDEE;CN=Bob Jones;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@example.com
ATTENDEE;CN=Carol White;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:carol@example.com
BEGIN:VALARM
TRIGGER:-PT15M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
BEGIN:VALARM
TRIGGER:-PT5M
ACTION:AUDIO
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -1,16 +0,0 @@
{
"name": "@aris/source-caldav",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test .",
"test:live": "bun run scripts/test-live.ts"
},
"dependencies": {
"@aris/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7"
}
}

View File

@@ -1,64 +0,0 @@
/**
* Live test script for CalDavSource.
*
* Usage:
* bun run test-live.ts
*/
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")
const username = prompt("Username:")
const password = prompt("Password:")
const lookAheadRaw = prompt("Look-ahead days (default 0):")
if (!serverUrl || !username || !password) {
console.error("Server URL, username, and password are required.")
process.exit(1)
}
const lookAheadDays = Number(lookAheadRaw) || 0
const source = new CalDavSource({
serverUrl,
authMethod: "basic",
username,
password,
lookAheadDays,
})
const context = new Context()
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
const contextResult = await source.fetchContext(context)
const items = await source.fetchItems(context)
console.log("=== Context ===")
console.log(JSON.stringify(contextResult, null, 2))
console.log(`\n=== Feed Items (${items.length}) ===`)
for (const item of items) {
console.log(`\n--- ${item.data.title} ---`)
console.log(` ID: ${item.id}`)
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
console.log(` Start: ${item.data.startDate.toISOString()}`)
console.log(` End: ${item.data.endDate.toISOString()}`)
console.log(` All-day: ${item.data.isAllDay}`)
console.log(` Location: ${item.data.location ?? "(none)"}`)
console.log(` Status: ${item.data.status ?? "(none)"}`)
console.log(` Urgency: ${item.signals?.urgency}`)
console.log(` Relevance: ${item.signals?.timeRelevance}`)
if (item.data.attendees.length > 0) {
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
}
if (item.data.description) {
console.log(` Desc: ${item.data.description.slice(0, 100)}`)
}
}
if (items.length === 0) {
console.log("(no events found in the time window)")
}

View File

@@ -1,514 +0,0 @@
import type { ContextEntry } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import type {
CalDavDAVCalendar,
CalDavDAVClient,
CalDavDAVObject,
CalDavEventData,
} from "./types.ts"
import { CalDavSource, computeSignals } from "./caldav-source.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
function createContext(time: Date): Context {
return new Context(time)
}
/** Extract the CalendarContext value from fetchContext entries. */
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
if (!entries) return undefined
const entry = entries.find(([key]) => key === CalDavCalendarKey)
return entry?.[1] as CalendarContext | undefined
}
class MockDAVClient implements CalDavDAVClient {
credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0
lastTimeRange: { start: string; end: string } | null = null
private calendars: CalDavDAVCalendar[]
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
constructor(
calendars: CalDavDAVCalendar[],
objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
) {
this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl
}
async login(): Promise<void> {}
async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
this.fetchCalendarsCallCount++
return this.calendars
}
async fetchCalendarObjects(params: {
calendar: CalDavDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalDavDAVObject[]> {
this.lastTimeRange = params.timeRange
return this.objectsByCalendarUrl[params.calendar.url] ?? []
}
}
function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
return new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
davClient: client,
lookAheadDays,
})
}
describe("CalDavSource", () => {
test("has correct id", () => {
const client = new MockDAVClient([], {})
const source = createSource(client)
expect(source.id).toBe("aris.caldav")
})
test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], {})
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([])
})
test("returns feed items from a single calendar", 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)
expect(items[0]!.type).toBe("caldav-event")
expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
expect(items[0]!.data.title).toBe("Team Standup")
expect(items[0]!.data.location).toBe("Conference Room A")
expect(items[0]!.data.calendarName).toBe("Work")
expect(items[0]!.data.attendees).toHaveLength(2)
expect(items[0]!.data.alarms).toHaveLength(2)
})
test("returns feed items from multiple calendars", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
"/cal/personal": [
{
url: "/cal/personal/event2.ics",
data: loadFixture("all-day-event.ics"),
},
],
}
const client = new MockDAVClient(
[
{ url: "/cal/work", displayName: "Work" },
{ url: "/cal/personal", displayName: "Personal" },
],
objects,
)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(2)
const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup).toBeDefined()
expect(standup!.data.calendarName).toBe("Work")
expect(holiday).toBeDefined()
expect(holiday!.data.calendarName).toBe("Personal")
expect(holiday!.data.isAllDay).toBe(true)
})
test("skips objects with non-string data", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/empty.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)
expect(items[0]!.data.title).toBe("Team Standup")
})
test("uses context time as feed item timestamp", 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 now = new Date("2026-01-15T12:00:00Z")
const items = await source.fetchItems(createContext(now))
expect(items[0]!.timestamp).toEqual(now)
})
test("assigns signals based on event proximity", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ 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)
// 2 hours before the event at 14:00
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours
expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming)
expect(holiday!.signals!.urgency).toBe(0.3) // all-day
expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("handles calendar with non-string displayName", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/weird": [
{
url: "/cal/weird/event1.ics",
data: loadFixture("minimal-event.ics"),
},
],
}
const client = new MockDAVClient(
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects,
)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items[0]!.data.calendarName).toBeNull()
})
test("handles recurring events with exceptions", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{
url: "/cal/work/recurring.ics",
data: loadFixture("recurring-event.ics"),
},
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
expect(items).toHaveLength(2)
const base = items.find((i) => i.data.title === "Weekly Sync")
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
expect(base).toBeDefined()
expect(base!.data.recurrenceId).toBeNull()
expect(exception).toBeDefined()
expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-")
})
test("caches events within the same refresh cycle", 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 context = createContext(new Date("2026-01-15T12:00:00Z"))
await source.fetchContext(context)
await source.fetchItems(context)
// Same context.time reference — fetchEvents should only hit the client once
expect(client.fetchCalendarsCallCount).toBe(1)
})
test("uses timezone for time range when provided", 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)
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
davClient: client,
timeZone: "Australia/Sydney",
})
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
})
test("defaults to UTC midnight when no timezone provided", 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)
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
})
test("refetches events for a different context time", 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)
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
// Different context.time references — should fetch twice
expect(client.fetchCalendarsCallCount).toBe(2)
})
})
describe("CalDavSource.fetchContext", () => {
test("returns empty context when no calendars exist", async () => {
const client = new MockDAVClient([], {})
const source = createSource(client)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([])
expect(calendar!.nextEvent).toBeNull()
expect(calendar!.hasTodayEvents).toBe(false)
expect(calendar!.todayEventCount).toBe(0)
})
test("identifies in-progress events", 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)
// 14:30 is during the 14:00-15:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
})
test("identifies next upcoming event", 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)
// 12:00 is before the 14:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull()
expect(calendar!.nextEvent!.title).toBe("Team Standup")
})
test("excludes all-day events from inProgress and nextEvent", 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 entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull()
expect(calendar!.hasTodayEvents).toBe(true)
expect(calendar!.todayEventCount).toBe(1)
})
test("counts all events including all-day in todayEventCount", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ 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 entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true)
})
})
describe("computeSignals", () => {
const now = new Date("2026-01-15T12:00:00Z")
function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
return {
uid: "test-uid",
title: "Test",
startDate: new Date("2026-01-15T14:00:00Z"),
endDate: new Date("2026-01-15T15:00:00Z"),
isAllDay: false,
location: null,
description: null,
calendarName: null,
status: null,
url: null,
organizer: null,
attendees: [],
alarms: [],
recurrenceId: null,
...overrides,
}
}
test("all-day events get urgency 0.3 and ambient relevance", () => {
const event = makeEvent({ isAllDay: true })
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.3)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:20:00Z"),
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.9)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("events starting exactly at 30 minutes get urgency 0.9", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:30:00Z"),
})
expect(computeSignals(event, now).urgency).toBe(0.9)
})
test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T13:00:00Z"),
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.7)
expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming)
})
test("events later today get urgency 0.5", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T20:00:00Z"),
})
expect(computeSignals(event, now).urgency).toBe(0.5)
})
test("in-progress events get urgency 0.8 and imminent relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T11:00:00Z"),
endDate: new Date("2026-01-15T13:00:00Z"),
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.8)
expect(signals.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("fully past events get urgency 0.2 and ambient relevance", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T09:00:00Z"),
endDate: new Date("2026-01-15T10:00:00Z"),
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.2)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("events on future days get urgency 0.2", () => {
const event = makeEvent({
startDate: new Date("2026-01-16T10:00:00Z"),
})
expect(computeSignals(event, now).urgency).toBe(0.2)
})
test("urgency boundaries are correct", () => {
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
const event31min = makeEvent({
startDate: new Date("2026-01-15T12:31:00Z"),
})
expect(computeSignals(event31min, now).urgency).toBe(0.7)
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
const event2h1m = makeEvent({
startDate: new Date("2026-01-15T14:01:00Z"),
})
expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
})
test("cancelled events get urgency 0.1 regardless of timing", () => {
const event = makeEvent({
status: "cancelled",
startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.1)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("uses timezone for 'later today' boundary", () => {
// now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9)
// event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST
const event = makeEvent({
startDate: new Date("2026-01-15T15:30:00Z"),
})
// Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today"
expect(computeSignals(event, now).urgency).toBe(0.5)
// With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST),
// event is after that → "future days"
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
})
})

View File

@@ -1,351 +0,0 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
// -- Source options --
interface CalDavSourceBaseOptions {
serverUrl: string
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */
timeZone?: string
/** Optional DAV client for testing. */
davClient?: CalDavDAVClient
}
interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions {
authMethod: "basic"
username: string
password: string
}
interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions {
authMethod: "oauth"
accessToken: string
refreshToken: string
tokenUrl: string
expiration?: number
clientId?: string
clientSecret?: string
}
export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions
const DEFAULT_LOOK_AHEAD_DAYS = 0
/**
* A FeedSource that fetches calendar events from any CalDAV server.
*
* Supports Basic auth (username/password) and OAuth (access token + refresh token).
* The server URL is provided at construction time.
*
* @example
* ```ts
* // Basic auth (self-hosted servers)
* const source = new CalDavSource({
* serverUrl: "https://nextcloud.example.com/remote.php/dav",
* authMethod: "basic",
* username: "user",
* password: "pass",
* })
*
* // OAuth (cloud providers)
* const source = new CalDavSource({
* serverUrl: "https://caldav.provider.com",
* authMethod: "oauth",
* accessToken: "...",
* refreshToken: "...",
* tokenUrl: "https://provider.com/oauth/token",
* })
* ```
*/
export class CalDavSource implements FeedSource<CalDavFeedItem> {
readonly id = "aris.caldav"
private options: CalDavSourceOptions | null
private readonly lookAheadDays: number
private readonly timeZone: string | undefined
private readonly injectedClient: CalDavDAVClient | null
private clientPromise: Promise<CalDavDAVClient> | null = null
private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null
private pendingFetch: { time: Date; promise: Promise<CalDavEventData[]> } | null = null
constructor(options: CalDavSourceOptions) {
this.options = options
this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
this.timeZone = options.timeZone
this.injectedClient = options.davClient ?? null
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return [
[
CalDavCalendarKey,
{
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
],
]
}
const now = context.time
const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled)
const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
const upcoming = active
.filter((e) => !e.isAllDay && e.startDate > now)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
const calendarContext: CalendarContext = {
inProgress,
nextEvent: upcoming[0] ?? null,
hasTodayEvents: events.length > 0,
todayEventCount: events.length,
}
return [[CalDavCalendarKey, calendarContext]]
}
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now, this.timeZone))
}
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return Promise.resolve(this.cachedEvents.events)
}
// Deduplicate concurrent fetches for the same context.time reference
if (this.pendingFetch && this.pendingFetch.time === context.time) {
return this.pendingFetch.promise
}
const promise = this.doFetchEvents(context).finally(() => {
if (this.pendingFetch?.promise === promise) {
this.pendingFetch = null
}
})
this.pendingFetch = { time: context.time, promise }
return promise
}
private async doFetchEvents(context: Context): Promise<CalDavEventData[]> {
const client = await this.connectClient()
const calendars = await client.fetchCalendars()
const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone)
const results = await Promise.allSettled(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | undefined
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
return { objects, calendarName }
}),
)
const allEvents: CalDavEventData[] = []
for (const result of results) {
if (result.status === "rejected") {
console.warn("[aris.caldav] Failed to fetch calendar:", result.reason)
continue
}
const { objects, calendarName } = result.value
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}
private connectClient(): Promise<CalDavDAVClient> {
if (this.injectedClient) {
return Promise.resolve(this.injectedClient)
}
if (!this.clientPromise) {
this.clientPromise = this.createAndLoginClient().catch((err) => {
this.clientPromise = null
throw err
})
}
return this.clientPromise
}
private async createAndLoginClient(): Promise<CalDavDAVClient> {
const opts = this.options
if (!opts) {
throw new Error("CalDavSource options have already been consumed")
}
let client: CalDavDAVClient
if (opts.authMethod === "basic") {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
username: opts.username,
password: opts.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
})
} else {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
tokenUrl: opts.tokenUrl,
refreshToken: opts.refreshToken,
accessToken: opts.accessToken,
expiration: opts.expiration,
clientId: opts.clientId,
clientSecret: opts.clientSecret,
},
authMethod: "Oauth",
defaultAccountType: "caldav",
})
}
await client.login()
this.options = null
return client
}
}
function computeTimeRange(
now: Date,
lookAheadDays: number,
timeZone?: string,
): { start: Date; end: Date } {
const start = startOfDay(now, timeZone)
const end = new Date(start.getTime() + (1 + lookAheadDays) * 24 * 60 * 60 * 1000)
return { start, end }
}
/**
* Returns midnight (start of day) as a UTC Date.
* When timeZone is provided, "midnight" is local midnight in that timezone
* converted to UTC. Otherwise, UTC midnight.
*/
function startOfDay(date: Date, timeZone?: string): Date {
if (!timeZone) {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
// Extract the local year/month/day in the target timezone
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date)
const year = Number(parts.find((p) => p.type === "year")!.value)
const month = Number(parts.find((p) => p.type === "month")!.value)
const day = Number(parts.find((p) => p.type === "day")!.value)
// Binary-search-free approach: construct a UTC date at the local date's noon,
// then use the timezone offset at that moment to find local midnight in UTC.
const noonUtc = Date.UTC(year, month - 1, day, 12, 0, 0)
const noonLocal = new Date(noonUtc).toLocaleString("sv-SE", { timeZone, hour12: false })
// sv-SE locale formats as "YYYY-MM-DD HH:MM:SS" which Date can parse
const noonLocalMs = new Date(noonLocal + "Z").getTime()
const offsetMs = noonLocalMs - noonUtc
return new Date(Date.UTC(year, month - 1, day) - offsetMs)
}
export function computeSignals(
event: CalDavEventData,
now: Date,
timeZone?: string,
): FeedItemSignals {
if (event.status === CalDavEventStatus.Cancelled) {
return { urgency: 0.1, timeRelevance: TimeRelevance.Ambient }
}
if (event.isAllDay) {
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
}
const msUntilStart = event.startDate.getTime() - now.getTime()
// Event already started
if (msUntilStart < 0) {
const isInProgress = now.getTime() < event.endDate.getTime()
return isInProgress
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
}
// Later today (using local day boundary when timeZone is set)
const todayStart = startOfDay(now, timeZone)
const endOfDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000)
if (event.startDate.getTime() < endOfDay.getTime()) {
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
}
// Future days
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: CalDavFeedItemType.Event,
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),
}
}

View File

@@ -1,24 +0,0 @@
import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core"
import type { CalDavEventData } from "./types.ts"
/**
* Calendar context for downstream sources.
*
* Provides a snapshot of the user's upcoming CalDAV events so other sources
* can adapt (e.g. a commute source checking if there's a meeting soon).
*/
export interface CalendarContext {
/** Events happening right now */
inProgress: CalDavEventData[]
/** Next upcoming event, if any */
nextEvent: CalDavEventData | null
/** Whether the user has any events today */
hasTodayEvents: boolean
/** Total number of events today */
todayEventCount: number
}
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")

View File

@@ -1,107 +0,0 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { parseICalEvents } from "./ical-parser.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
describe("parseICalEvents", () => {
test("parses a full event with all fields", () => {
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("single-event-001@test")
expect(event.title).toBe("Team Standup")
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
expect(event.isAllDay).toBe(false)
expect(event.location).toBe("Conference Room A")
expect(event.description).toBe("Daily standup meeting")
expect(event.calendarName).toBe("Work")
expect(event.status).toBe("confirmed")
expect(event.url).toBe("https://example.com/meeting/123")
expect(event.organizer).toBe("Alice Smith")
expect(event.recurrenceId).toBeNull()
expect(event.attendees).toHaveLength(2)
expect(event.attendees[0]).toEqual({
name: "Bob Jones",
email: "bob@example.com",
role: "required",
status: "accepted",
})
expect(event.attendees[1]).toEqual({
name: "Carol White",
email: "carol@example.com",
role: "optional",
status: "tentative",
})
expect(event.alarms).toHaveLength(2)
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
})
test("parses an all-day event with optional fields as null", () => {
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Company Holiday")
expect(event.calendarName).toBeNull()
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
})
test("parses recurring event with exception", () => {
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
expect(events[0]!.uid).toBe("recurring-001@test")
expect(events[1]!.uid).toBe("recurring-001@test")
const base = events.find((e) => e.title === "Weekly Sync")
expect(base).toBeDefined()
expect(base!.recurrenceId).toBeNull()
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
expect(exception).toBeDefined()
expect(exception!.recurrenceId).not.toBeNull()
})
test("parses minimal event with defaults", () => {
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("minimal-001@test")
expect(event.title).toBe("Quick Chat")
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.status).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
expect(event.recurrenceId).toBeNull()
})
test("parses cancelled status", () => {
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
expect(events[0]!.status).toBe("cancelled")
})
})

View File

@@ -1,153 +0,0 @@
import ICAL from "ical.js"
import {
AttendeeRole,
AttendeeStatus,
CalDavEventStatus,
type CalDavAlarm,
type CalDavAttendee,
type CalDavEventData,
} from "./types.ts"
/**
* Parses a raw iCalendar string and extracts all VEVENT components
* into CalDavEventData objects.
*
* @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to
*/
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent")
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName),
)
}
function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null,
): CalDavEventData {
const event = new ICAL.Event(vevent)
return {
uid: event.uid ?? "",
title: event.summary ?? "",
startDate: event.startDate?.toJSDate() ?? new Date(0),
endDate: event.endDate?.toJSDate() ?? new Date(0),
isAllDay: event.startDate?.isDate ?? false,
location: event.location ?? null,
description: event.description ?? null,
calendarName,
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
alarms: parseAlarms(vevent),
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
}
}
function parseStatus(raw: string | null): CalDavEventStatus | null {
if (!raw) return null
switch (raw.toLowerCase()) {
case "confirmed":
return CalDavEventStatus.Confirmed
case "tentative":
return CalDavEventStatus.Tentative
case "cancelled":
return CalDavEventStatus.Cancelled
default:
return null
}
}
function parseOrganizer(
value: string | null,
vevent: InstanceType<typeof ICAL.Component>,
): string | null {
if (!value) return null
// Try CN parameter first
const prop = vevent.getFirstProperty("organizer")
if (prop) {
const cn = prop.getParameter("cn") as string | undefined
if (cn) return cn
}
// Fall back to mailto: value
return value.replace(/^mailto:/i, "")
}
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
if (properties.length === 0) return []
return properties.flatMap((prop) => {
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
const p = prop as InstanceType<typeof ICAL.Property>
const value = asStringOrNull(p.getFirstValue())
const cn = asStringOrNull(p.getParameter("cn"))
const role = asStringOrNull(p.getParameter("role"))
const partstat = asStringOrNull(p.getParameter("partstat"))
return [
{
name: cn,
email: value ? value.replace(/^mailto:/i, "") : null,
role: parseAttendeeRole(role),
status: parseAttendeeStatus(partstat),
},
]
})
}
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
if (!raw) return null
switch (raw.toUpperCase()) {
case "CHAIR":
return AttendeeRole.Chair
case "REQ-PARTICIPANT":
return AttendeeRole.Required
case "OPT-PARTICIPANT":
return AttendeeRole.Optional
default:
return null
}
}
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
if (!raw) return null
switch (raw.toUpperCase()) {
case "ACCEPTED":
return AttendeeStatus.Accepted
case "DECLINED":
return AttendeeStatus.Declined
case "TENTATIVE":
return AttendeeStatus.Tentative
case "NEEDS-ACTION":
return AttendeeStatus.NeedsAction
default:
return null
}
}
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
const valarms = vevent.getAllSubcomponents("valarm")
if (!valarms || valarms.length === 0) return []
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
const trigger = valarm.getFirstPropertyValue("trigger")
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
return {
trigger: trigger ? trigger.toString() : "",
action: action ?? "DISPLAY",
}
})
}
function asStringOrNull(value: unknown): string | null {
return typeof value === "string" ? value : null
}

View File

@@ -1,16 +0,0 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents } from "./ical-parser.ts"
export {
AttendeeRole,
AttendeeStatus,
CalDavEventStatus,
CalDavFeedItemType,
type CalDavAlarm,
type CalDavAttendee,
type CalDavDAVCalendar,
type CalDavDAVClient,
type CalDavDAVObject,
type CalDavEventData,
type CalDavFeedItem,
} from "./types.ts"

View File

@@ -1,101 +0,0 @@
import type { FeedItem } from "@aris/core"
// -- Event status --
export const CalDavEventStatus = {
Confirmed: "confirmed",
Tentative: "tentative",
Cancelled: "cancelled",
} as const
export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
// -- Attendee types --
export const AttendeeRole = {
Chair: "chair",
Required: "required",
Optional: "optional",
} as const
export type AttendeeRole = (typeof AttendeeRole)[keyof typeof AttendeeRole]
export const AttendeeStatus = {
Accepted: "accepted",
Declined: "declined",
Tentative: "tentative",
NeedsAction: "needs-action",
} as const
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
export interface CalDavAttendee {
name: string | null
email: string | null
role: AttendeeRole | null
status: AttendeeStatus | null
}
// -- Alarm --
export interface CalDavAlarm {
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
trigger: string
/** e.g. "DISPLAY", "AUDIO" */
action: string
}
// -- Event data --
export interface CalDavEventData extends Record<string, unknown> {
uid: string
title: string
startDate: Date
endDate: Date
isAllDay: boolean
location: string | null
description: string | null
calendarName: string | null
status: CalDavEventStatus | null
url: string | null
organizer: string | null
attendees: CalDavAttendee[]
alarms: CalDavAlarm[]
recurrenceId: string | null
}
// -- Feed item type --
export const CalDavFeedItemType = {
Event: "caldav-event",
} as const
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
// -- Feed item --
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData>
// -- DAV client interface --
export interface CalDavDAVObject {
data?: unknown
etag?: string
url: string
}
export interface CalDavDAVCalendar {
displayName?: string | Record<string, unknown>
url: string
}
/** Subset of tsdav's DAVClient used by CalDavSource. */
export interface CalDavDAVClient {
login(): Promise<void>
fetchCalendars(): Promise<CalDavDAVCalendar[]>
fetchCalendarObjects(params: {
calendar: CalDavDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalDavDAVObject[]>
credentials: Record<string, unknown>
}

View File

@@ -10,4 +10,4 @@ export interface NextEvent {
location: string | null
}
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")

View File

@@ -3,19 +3,19 @@ import type { FeedItem } from "@aris/core"
import type { CalendarEventData } from "./types"
export const CalendarFeedItemType = {
Event: "calendar-event",
AllDay: "calendar-all-day",
event: "calendar-event",
allDay: "calendar-all-day",
} as const
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
export interface CalendarEventFeedItem extends FeedItem<
typeof CalendarFeedItemType.Event,
typeof CalendarFeedItemType.event,
CalendarEventData
> {}
export interface CalendarAllDayFeedItem extends FeedItem<
typeof CalendarFeedItemType.AllDay,
typeof CalendarFeedItemType.allDay,
CalendarEventData
> {}

View File

@@ -1,10 +1,10 @@
import { Context, TimeRelevance } from "@aris/core"
import { TimeRelevance, contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json"
import { NextEventKey, type NextEvent } from "./calendar-context"
import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source"
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
}
function createContext(time?: Date): Context {
return new Context(time ?? NOW)
return { time: time ?? NOW }
}
describe("GoogleCalendarSource", () => {
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.Event)
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
expect(timedItems.length).toBe(4)
})
@@ -77,7 +77,7 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.AllDay)
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
expect(allDayItems.length).toBe(1)
})
@@ -229,16 +229,15 @@ describe("GoogleCalendarSource", () => {
test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const entries = await source.fetchContext(createContext())
const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull()
expect(entries).toHaveLength(1)
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
expect(key).toEqual(NextEventKey)
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
// evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent.title).toBe("1:1 with Manager")
expect(nextEvent.minutesUntilStart).toBe(10)
expect(nextEvent.location).toBeNull()
expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent!.location).toBeNull()
})
test("includes location when available", async () => {
@@ -256,11 +255,12 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const entries = await source.fetchContext(createContext())
const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull()
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
expect(nextEvent.location).toBe("123 Main St")
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
})
test("skips ongoing events for next-event context", async () => {

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import type {
ApiCalendarEvent,
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
* .register(calendarSource)
*
* // Access next-event context in downstream sources
* const next = context.get(NextEventKey)
* const next = contextValue(context, NextEventKey)
* if (next && next.minutesUntilStart < 15) {
* // remind user
* }
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
location: nextTimedEvent.location,
}
return [[NextEventKey, nextEvent]]
return { [NextEventKey]: nextEvent }
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
@@ -209,7 +209,7 @@ function createFeedItem(
nowMs: number,
lookaheadMs: number,
): CalendarFeedItem {
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return {
id: `calendar-${event.calendarId}-${event.eventId}`,

View File

@@ -1,6 +1,7 @@
export { NextEventKey, type NextEvent } from "./calendar-context"
export {
CalendarFeedItemType,
type CalendarFeedItemType as CalendarFeedItemTypeType,
type CalendarAllDayFeedItem,
type CalendarEventFeedItem,
type CalendarFeedItem,
@@ -9,6 +10,7 @@ export { DefaultGoogleCalendarClient } from "./google-calendar-api"
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
export {
EventStatus,
type EventStatus as EventStatusType,
type ApiCalendarEvent,
type ApiEventDateTime,
type CalendarEventData,

View File

@@ -1,8 +1,6 @@
import { describe, expect, mock, test } from "bun:test"
import type { Location } from "./types.ts"
import { LocationKey, LocationSource } from "./location-source.ts"
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location {
return {
@@ -41,8 +39,8 @@ describe("LocationSource", () => {
const location = createLocation()
source.pushLocation(location)
const entries = await source.fetchContext()
expect(entries).toEqual([[LocationKey, location]])
const context = await source.fetchContext()
expect(context).toEqual({ [LocationKey]: location })
})
})
@@ -67,7 +65,7 @@ describe("LocationSource", () => {
source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith([[LocationKey, location]])
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
})
})

View File

@@ -1,11 +1,11 @@
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts"
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
export const LocationKey: ContextKey<Location> = contextKey("location")
/**
* A FeedSource that provides location context.
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
private readonly historySize: number
private locations: Location[] = []
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
private listeners = new Set<(update: Partial<Context>) => void>()
constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1
@@ -59,9 +59,8 @@ export class LocationSource implements FeedSource {
if (this.locations.length > this.historySize) {
this.locations.shift()
}
const entries: readonly ContextEntry[] = [[LocationKey, location]]
this.listeners.forEach((listener) => {
listener(entries)
listener({ [LocationKey]: location })
})
}
@@ -79,16 +78,16 @@ export class LocationSource implements FeedSource {
return this.locations
}
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
this.listeners.add(callback)
return () => {
this.listeners.delete(callback)
}
}
async fetchContext(): Promise<readonly ContextEntry[] | null> {
async fetchContext(): Promise<Partial<Context> | null> {
if (this.lastLocation) {
return [[LocationKey, this.lastLocation]]
return { [LocationKey]: this.lastLocation }
}
return null
}

View File

@@ -1,13 +1,12 @@
export { TflSource } from "./tfl-source.ts"
export { TflApi } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts"
export {
TflFeedItemType,
type ITflApi,
type StationLocation,
type TflAlertData,
type TflAlertFeedItem,
type TflAlertSeverity,
type TflLineStatus,
type TflSourceOptions,
export type {
ITflApi,
StationLocation,
TflAlertData,
TflAlertFeedItem,
TflAlertSeverity,
TflLineStatus,
TflSourceOptions,
} from "./types.ts"

View File

@@ -1,4 +1,5 @@
import { Context } from "@aris/core"
import type { Context } from "@aris/core"
import { LocationKey, type Location } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -80,9 +81,9 @@ class FixtureTflApi implements ITflApi {
}
function createContext(location?: Location): Context {
const ctx = new Context(new Date("2026-01-15T12:00:00Z"))
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
if (location) {
ctx.set([[LocationKey, location]])
ctx[LocationKey] = location
}
return ctx
}

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { type } from "arktype"
@@ -15,7 +15,6 @@ import type {
} from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts"
import { TflFeedItemType } from "./types.ts"
const setLinesInput = lineId.array()
@@ -112,7 +111,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
}
}
async fetchContext(): Promise<readonly ContextEntry[] | null> {
async fetchContext(): Promise<null> {
return null
}
@@ -129,7 +128,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.client.fetchStations(),
])
const location = context.get(LocationKey)
const location = contextValue(context, LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location
@@ -151,7 +150,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
return {
id: `tfl-alert-${status.lineId}-${status.severity}`,
type: TflFeedItemType.Alert,
type: "tfl-alert",
timestamp: context.time,
data,
signals,

View File

@@ -20,13 +20,7 @@ export interface TflAlertData extends Record<string, unknown> {
closestStationDistance: number | null
}
export const TflFeedItemType = {
Alert: "tfl-alert",
} as const
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
export interface TflSourceOptions {
apiKey?: string

View File

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

View File

@@ -3,10 +3,10 @@ import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = {
Current: "weather-current",
Hourly: "weather-hourly",
Daily: "weather-daily",
Alert: "weather-alert",
current: "weather-current",
hourly: "weather-hourly",
daily: "weather-daily",
alert: "weather-alert",
} as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
@@ -28,7 +28,7 @@ export type CurrentWeatherData = {
}
export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Current,
typeof WeatherFeedItemType.current,
CurrentWeatherData
> {}
@@ -49,7 +49,7 @@ export type HourlyWeatherData = {
}
export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly,
typeof WeatherFeedItemType.hourly,
HourlyWeatherData
> {}
@@ -68,7 +68,7 @@ export type DailyWeatherData = {
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily,
typeof WeatherFeedItemType.daily,
DailyWeatherData
> {}
@@ -86,7 +86,7 @@ export type WeatherAlertData = {
}
export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.Alert,
typeof WeatherFeedItemType.alert,
WeatherAlertData
> {}

View File

@@ -1,8 +1,14 @@
export { WeatherKey, type Weather } from "./weather-context"
export { WeatherSource, Units, type WeatherSourceOptions } from "./weather-source"
export {
WeatherSource,
Units,
type Units as UnitsType,
type WeatherSourceOptions,
} from "./weather-source"
export {
WeatherFeedItemType,
type WeatherFeedItemType as WeatherFeedItemTypeType,
type WeatherFeedItem,
type CurrentWeatherFeedItem,
type CurrentWeatherData,
@@ -21,6 +27,11 @@ export {
Certainty,
PrecipitationType,
DefaultWeatherKitClient,
type ConditionCode as ConditionCodeType,
type Severity as SeverityType,
type Urgency as UrgencyType,
type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType,
type WeatherKitClient,
type WeatherKitCredentials,
type WeatherKitQueryOptions,

View File

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

View File

@@ -24,4 +24,4 @@ export interface Weather {
daylight: boolean
}
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather")
export const WeatherKey: ContextKey<Weather> = contextKey("weather")

View File

@@ -1,6 +1,4 @@
import type { FeedSource } from "@aris/core"
import { Context } from "@aris/core"
import { contextValue, type Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -8,7 +6,7 @@ import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context"
import { WeatherKey } from "./weather-context"
import { WeatherSource, Units } from "./weather-source"
const mockCredentials = {
@@ -25,9 +23,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
}
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
if (location) {
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]])
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
}
return ctx
}
@@ -65,19 +63,18 @@ describe("WeatherSource", () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const entries = await source.fetchContext(context)
expect(entries).not.toBeNull()
expect(entries).toHaveLength(1)
const result = await source.fetchContext(context)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, WeatherKey)
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather]
expect(key).toEqual(WeatherKey)
expect(typeof weather.temperature).toBe("number")
expect(typeof weather.temperatureApparent).toBe("number")
expect(typeof weather.condition).toBe("string")
expect(typeof weather.humidity).toBe("number")
expect(typeof weather.uvIndex).toBe("number")
expect(typeof weather.windSpeed).toBe("number")
expect(typeof weather.daylight).toBe("boolean")
expect(weather).toBeDefined()
expect(typeof weather!.temperature).toBe("number")
expect(typeof weather!.temperatureApparent).toBe("number")
expect(typeof weather!.condition).toBe("string")
expect(typeof weather!.humidity).toBe("number")
expect(typeof weather!.uvIndex).toBe("number")
expect(typeof weather!.windSpeed).toBe("number")
expect(typeof weather!.daylight).toBe("boolean")
})
test("converts temperature to imperial", async () => {
@@ -87,12 +84,12 @@ describe("WeatherSource", () => {
})
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const entries = await source.fetchContext(context)
expect(entries).not.toBeNull()
const result = await source.fetchContext(context)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, WeatherKey)
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
// Fixture has temperature around 10°C, imperial should be around 50°F
expect(weather.temperature).toBeGreaterThan(40)
expect(weather!.temperature).toBeGreaterThan(40)
})
})
@@ -113,9 +110,9 @@ describe("WeatherSource", () => {
const items = await source.fetchItems(context)
expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
})
test("applies hourly and daily limits", async () => {
@@ -128,8 +125,8 @@ describe("WeatherSource", () => {
const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2)
@@ -161,7 +158,7 @@ describe("WeatherSource", () => {
expect(item.signals!.timeRelevance).toBeDefined()
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
})
@@ -176,44 +173,16 @@ 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", () => {
test("does not implement onContextUpdate", () => {
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onContextUpdate).toBeUndefined()
})
test("does not implement onItemsUpdate", () => {
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onItemsUpdate).toBeUndefined()
})
})

View File

@@ -1,10 +1,9 @@
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { TimeRelevance, UnknownActionError, contextValue } 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,
@@ -87,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
* })
*
* // Access weather context in downstream sources
* const weather = context.get(WeatherKey)
* const weather = contextValue(context, WeatherKey)
* if (weather?.condition === "Rain") {
* // suggest umbrella
* }
@@ -120,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const location = context.get(LocationKey)
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const location = contextValue(context, LocationKey)
if (!location) {
return null
}
@@ -148,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
daylight: response.currentWeather.daylight,
}
return [[WeatherKey, weather]]
return { [WeatherKey]: weather }
}
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey)
const location = contextValue(context, LocationKey)
if (!location) {
return []
}
@@ -292,7 +291,7 @@ function createCurrentWeatherFeedItem(
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current,
type: WeatherFeedItemType.current,
timestamp,
data: {
conditionCode: current.conditionCode,
@@ -310,12 +309,6 @@ function createCurrentWeatherFeedItem(
windSpeed: convertSpeed(current.windSpeed, units),
},
signals,
slots: {
insight: {
description: currentWeatherInsightPrompt,
content: null,
},
},
}
}
@@ -332,7 +325,7 @@ function createHourlyWeatherFeedItem(
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly,
type: WeatherFeedItemType.hourly,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
@@ -366,7 +359,7 @@ function createDailyWeatherFeedItem(
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily,
type: WeatherFeedItemType.daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
@@ -393,7 +386,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
return {
id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert,
type: WeatherFeedItemType.alert,
timestamp,
data: {
alertId: alert.id,