Compare commits

..

2 Commits

Author SHA1 Message Date
f806b78fb7 fix: correct misleading sort order comments
Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:25:03 +00:00
65ca50bf36 feat: add boost directive to FeedEnhancement
Post-processors can now return a boost map (item ID -> score)
to promote or demote items in the feed ordering. Scores from
multiple processors are summed and clamped to [-1, 1].

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 17:20:36 +00:00
40 changed files with 319 additions and 681 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 { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
async executeAction(): Promise<unknown> { async executeAction(): Promise<unknown> {
return undefined return undefined
}, },
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
return null return null
}, },
async fetchItems() { 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 { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
async executeAction(): Promise<unknown> { async executeAction(): Promise<unknown> {
return undefined return undefined
}, },
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
return null return null
}, },
async fetchItems() { async fetchItems() {

View File

@@ -1,10 +1,8 @@
import type { ContextEntry } from "./context" import type { Context } from "./context"
import type { ContextProvider } from "./context-provider" import type { ContextProvider } from "./context-provider"
import { contextKey } from "./context"
interface ContextUpdatable { interface ContextUpdatable {
pushContextUpdate(entries: readonly ContextEntry[]): void pushContextUpdate(update: Partial<Context>): void
} }
export interface ProviderError { export interface ProviderError {
@@ -56,7 +54,7 @@ export class ContextBridge {
this.providers.set(provider.key, provider as ContextProvider) this.providers.set(provider.key, provider as ContextProvider)
const cleanup = provider.onUpdate((value) => { const cleanup = provider.onUpdate((value) => {
this.controller.pushContextUpdate([[contextKey(provider.key), value]]) this.controller.pushContextUpdate({ [provider.key]: value })
}) })
this.cleanups.push(cleanup) this.cleanups.push(cleanup)
@@ -69,7 +67,7 @@ export class ContextBridge {
* Returns errors from providers that failed to fetch. * Returns errors from providers that failed to fetch.
*/ */
async refresh(): Promise<RefreshResult> { async refresh(): Promise<RefreshResult> {
const collected: ContextEntry[] = [] const updates: Partial<Context> = {}
const errors: ProviderError[] = [] const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries()) const entries = Array.from(this.providers.entries())
@@ -80,7 +78,7 @@ export class ContextBridge {
entries.forEach(([key], i) => { entries.forEach(([key], i) => {
const result = results[i] const result = results[i]
if (result?.status === "fulfilled") { if (result?.status === "fulfilled") {
collected.push([contextKey(key), result.value]) updates[key] = result.value
} else if (result?.status === "rejected") { } else if (result?.status === "rejected") {
errors.push({ errors.push({
key, key,
@@ -89,7 +87,7 @@ export class ContextBridge {
} }
}) })
this.controller.pushContextUpdate(collected) this.controller.pushContextUpdate(updates)
return { errors } 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 * Each package defines its own keys with associated value types:
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }]) * ```ts
* and consumers can query by exact match or prefix match to get all values * const LocationKey: ContextKey<Location> = contextKey("location")
* of a given type across source instances. * ```
*/ */
export type ContextKey<T> = string & { __contextValue?: T }
// -- 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 --
/** /**
* Deterministic serialization of a context key for use as a Map key. * Creates a typed context 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.
*
* Supports exact-match lookups and prefix-match queries.
* Sources write context in topological order during refresh.
*/
export class 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 * @example
* ```ts * ```ts
* // Get all "nextEvent" values across calendar source instances * interface Location { lat: number; lng: number; accuracy: number }
* const events = context.find(contextKey("nextEvent")) * const LocationKey: ContextKey<Location> = contextKey("location")
* ``` * ```
*/ */
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> { export function contextKey<T>(key: string): ContextKey<T> {
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = [] return key as ContextKey<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 /**
* 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
} }
/** Returns the number of entries (excluding time). */ /**
get size(): number { * Arbitrary key-value bag representing the current state.
return this.store.size * Always includes `time`. Other keys are added by context providers.
} */
export interface Context {
time: Date
[key: string]: unknown
} }

View File

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

View File

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

View File

@@ -1,11 +1,9 @@
import type { ActionDefinition } from "./action" import type { ActionDefinition } from "./action"
import type { ContextEntry } from "./context" import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor" import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source" import type { FeedSource } from "./feed-source"
import { Context } from "./context"
export interface SourceError { export interface SourceError {
sourceId: string sourceId: string
error: Error error: Error
@@ -67,7 +65,7 @@ interface SourceGraph {
export class FeedEngine<TItems extends FeedItem = FeedItem> { export class FeedEngine<TItems extends FeedItem = FeedItem> {
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private graph: SourceGraph | null = null private graph: SourceGraph | null = null
private context: Context = new Context() private context: Context = { time: new Date() }
private subscribers = new Set<FeedSubscriber<TItems>>() private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = [] private cleanups: Array<() => void> = []
private started = false private started = false
@@ -140,14 +138,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const errors: SourceError[] = [] const errors: SourceError[] = []
// Reset context with fresh time // Reset context with fresh time
const context = new Context() let context: Context = { time: new Date() }
// Run fetchContext in topological order // Run fetchContext in topological order
for (const source of graph.sorted) { for (const source of graph.sorted) {
try { try {
const entries = await source.fetchContext(context) const update = await source.fetchContext(context)
if (entries) { if (update) {
context.set(entries) context = { ...context, ...update }
} }
} catch (err) { } catch (err) {
errors.push({ errors.push({
@@ -179,7 +177,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items: processedItems, items: processedItems,
groupedItems, groupedItems,
errors: postProcessorErrors, errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], context, errors) } = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = { const result: FeedResult<TItems> = {
context, context,
@@ -215,8 +213,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const source of graph.sorted) { for (const source of graph.sorted) {
if (source.onContextUpdate) { if (source.onContextUpdate) {
const cleanup = source.onContextUpdate( const cleanup = source.onContextUpdate(
(entries) => { (update) => {
this.handleContextUpdate(source.id, entries) this.handleContextUpdate(source.id, update)
}, },
() => this.context, () => this.context,
) )
@@ -296,7 +294,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private async applyPostProcessors( private async applyPostProcessors(
items: TItems[], items: TItems[],
context: Context,
errors: SourceError[], errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> { ): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items let currentItems = items
@@ -307,7 +304,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const processor of this.postProcessors) { for (const processor of this.postProcessors) {
const snapshot = currentItems const snapshot = currentItems
try { try {
const enhancement = await processor(currentItems, context) const enhancement = await processor(currentItems)
if (enhancement.additionalItems?.length) { if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems. // Post-processors operate on FeedItem[] without knowledge of TItems.
@@ -367,9 +364,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this.graph return this.graph
} }
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void { private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
this.context.time = new Date() this.context = { ...this.context, ...update, time: new Date() }
this.context.set(entries)
// Re-run dependents and notify // Re-run dependents and notify
this.refreshDependents(sourceId) this.refreshDependents(sourceId)
@@ -384,9 +380,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const source = graph.sources.get(id) const source = graph.sources.get(id)
if (source) { if (source) {
try { try {
const entries = await source.fetchContext(this.context) const update = await source.fetchContext(this.context)
if (entries) { if (update) {
this.context.set(entries) this.context = { ...this.context, ...update }
} }
} catch { } catch {
// Errors during reactive updates are logged but don't stop propagation // Errors during reactive updates are logged but don't stop propagation
@@ -416,7 +412,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items: processedItems, items: processedItems,
groupedItems, groupedItems,
errors: postProcessorErrors, errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], this.context, errors) } = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = { const result: FeedResult<TItems> = {
context: this.context, context: this.context,

View File

@@ -1,12 +1,6 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import type { import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
ActionDefinition,
ContextEntry,
FeedItem,
FeedPostProcessor,
FeedSource,
} from "./index"
import { FeedEngine } from "./feed-engine" import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index" import { UnknownActionError } from "./index"
@@ -477,7 +471,7 @@ describe("FeedPostProcessor", () => {
test("post-processors run during reactive context updates", async () => { test("post-processors run during reactive context updates", async () => {
let callCount = 0 let callCount = 0
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
const source: FeedSource = { const source: FeedSource = {
id: "aris.reactive", id: "aris.reactive",
@@ -496,7 +490,9 @@ describe("FeedPostProcessor", () => {
}, },
} }
const engine = new FeedEngine().register(source).registerPostProcessor(async () => { const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++ callCount++
return {} return {}
}) })
@@ -508,7 +504,7 @@ describe("FeedPostProcessor", () => {
const countAfterStart = callCount const countAfterStart = callCount
// Trigger a reactive context update // Trigger a reactive context update
triggerUpdate!([]) triggerUpdate!({ foo: "bar" })
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart) expect(callCount).toBeGreaterThan(countAfterStart)
@@ -519,7 +515,7 @@ describe("FeedPostProcessor", () => {
test("post-processors run during reactive item updates", async () => { test("post-processors run during reactive item updates", async () => {
let callCount = 0 let callCount = 0
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null let triggerItemsUpdate: (() => void) | null = null
const source: FeedSource = { const source: FeedSource = {
id: "aris.reactive", id: "aris.reactive",
@@ -538,7 +534,9 @@ describe("FeedPostProcessor", () => {
}, },
} }
const engine = new FeedEngine().register(source).registerPostProcessor(async () => { const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++ callCount++
return {} return {}
}) })
@@ -549,7 +547,7 @@ describe("FeedPostProcessor", () => {
const countAfterStart = callCount const countAfterStart = callCount
// Trigger a reactive items update // Trigger a reactive items update
triggerItemsUpdate!([weatherItem("w1", 25)]) triggerItemsUpdate!()
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart) expect(callCount).toBeGreaterThan(countAfterStart)

View File

@@ -1,4 +1,3 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
export interface ItemGroup { export interface ItemGroup {
@@ -23,4 +22,4 @@ export interface FeedEnhancement {
* A function that transforms feed items and produces enhancement directives. * A function that transforms feed items and produces enhancement directives.
* Use named functions for meaningful error attribution. * Use named functions for meaningful error attribution.
*/ */
export type FeedPostProcessor = (items: FeedItem[], context: Context) => Promise<FeedEnhancement> export type FeedPostProcessor = (items: FeedItem[]) => Promise<FeedEnhancement>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Context // Context
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context" export type { Context, ContextKey } from "./context"
export { Context, contextKey, serializeKey } from "./context" export { contextKey, contextValue } from "./context"
// Actions // Actions
export type { ActionDefinition } from "./action" export type { ActionDefinition } from "./action"

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 { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit" import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
@@ -16,25 +15,14 @@ const mockCredentials = {
serviceId: "mock", serviceId: "mock",
} }
interface LocationData {
lat: number
lng: number
accuracy: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({ const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response, fetch: async () => response,
}) })
function createMockContext(location?: { lat: number; lng: number }): Context { const createMockContext = (location?: { lat: number; lng: number }): Context => ({
const ctx = new Context(new Date("2026-01-17T00:00:00Z")) time: new Date("2026-01-17T00:00:00Z"),
if (location) { location: location ? { ...location, accuracy: 10 } : undefined,
ctx.set([[LocationKey, { ...location, accuracy: 10 }]]) })
}
return ctx
}
describe("WeatherKitDataSource", () => { describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => { test("returns empty array when location is missing", async () => {
@@ -51,7 +39,7 @@ describe("WeatherKitDataSource", () => {
credentials: mockCredentials, credentials: mockCredentials,
}) })
expect(dataSource.type).toBe(WeatherFeedItemType.Current) expect(dataSource.type).toBe(WeatherFeedItemType.current)
}) })
test("throws error if neither client nor credentials provided", () => { test("throws error if neither client nor credentials provided", () => {
@@ -142,9 +130,9 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context) const items = await dataSource.query(context)
expect(items.length).toBeGreaterThan(0) expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).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.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true) expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
}) })
test("applies hourly and daily limits", async () => { test("applies hourly and daily limits", async () => {
@@ -157,8 +145,8 @@ describe("query() with mocked client", () => {
const items = await dataSource.query(context) const items = await dataSource.query(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly) const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily) const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3) expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2) expect(dailyItems.length).toBe(2)
@@ -188,8 +176,8 @@ describe("query() with mocked client", () => {
units: Units.imperial, units: Units.imperial,
}) })
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current) const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current) const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
expect(metricCurrent).toBeDefined() expect(metricCurrent).toBeDefined()
expect(imperialCurrent).toBeDefined() expect(imperialCurrent).toBeDefined()
@@ -215,7 +203,7 @@ describe("query() with mocked client", () => {
expect(item.signals!.timeRelevance).toBeDefined() 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).toBeDefined()
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5) 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 { import {
WeatherFeedItemType, WeatherFeedItemType,
@@ -40,18 +40,11 @@ export interface WeatherKitQueryConfig {
units?: Units units?: Units
} }
interface LocationData {
lat: number
lng: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> { export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12 private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7 private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.Current readonly type = WeatherFeedItemType.current
private readonly client: WeatherKitClient private readonly client: WeatherKitClient
private readonly hourlyLimit: number private readonly hourlyLimit: number
private readonly dailyLimit: number private readonly dailyLimit: number
@@ -66,8 +59,7 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
} }
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> { async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey) if (!context.location) {
if (!location) {
return [] return []
} }
@@ -75,8 +67,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
const timestamp = context.time const timestamp = context.time
const response = await this.client.fetch({ const response = await this.client.fetch({
lat: location.lat, lat: context.location.lat,
lng: location.lng, lng: context.location.lng,
}) })
const items: WeatherFeedItem[] = [] const items: WeatherFeedItem[] = []
@@ -236,7 +228,7 @@ function createCurrentWeatherFeedItem(
return { return {
id: `weather-current-${timestamp.getTime()}`, id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current, type: WeatherFeedItemType.current,
timestamp, timestamp,
data: { data: {
conditionCode: current.conditionCode, conditionCode: current.conditionCode,
@@ -270,7 +262,7 @@ function createHourlyWeatherFeedItem(
return { return {
id: `weather-hourly-${timestamp.getTime()}-${index}`, id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly, type: WeatherFeedItemType.hourly,
timestamp, timestamp,
data: { data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
@@ -304,7 +296,7 @@ function createDailyWeatherFeedItem(
return { return {
id: `weather-daily-${timestamp.getTime()}-${index}`, id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily, type: WeatherFeedItemType.daily,
timestamp, timestamp,
data: { data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
@@ -331,7 +323,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
return { return {
id: `weather-alert-${alert.id}`, id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert, type: WeatherFeedItemType.alert,
timestamp, timestamp,
data: { data: {
alertId: alert.id, alertId: alert.id,

View File

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

View File

@@ -5,8 +5,6 @@
* bun run test-live.ts * bun run test-live.ts
*/ */
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts" import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:") const serverUrl = prompt("CalDAV server URL:")
@@ -29,7 +27,7 @@ const source = new CalDavSource({
lookAheadDays, lookAheadDays,
}) })
const context = new Context() const context = { time: new Date() }
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`) console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)

View File

@@ -1,6 +1,6 @@
import type { ContextEntry } from "@aris/core" import type { Context } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core" import { TimeRelevance, contextValue } from "@aris/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs" import { readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
@@ -13,21 +13,14 @@ import type {
} from "./types.ts" } from "./types.ts"
import { CalDavSource, computeSignals } from "./caldav-source.ts" import { CalDavSource, computeSignals } from "./caldav-source.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" import { CalDavCalendarKey } from "./calendar-context.ts"
function loadFixture(name: string): string { function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
} }
function createContext(time: Date): Context { function createContext(time: Date): Context {
return new Context(time) return { 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 { class MockDAVClient implements CalDavDAVClient {
@@ -309,8 +302,8 @@ describe("CalDavSource.fetchContext", () => {
test("returns empty context when no calendars exist", async () => { test("returns empty context when no calendars exist", async () => {
const client = new MockDAVClient([], {}) const client = new MockDAVClient([], {})
const source = createSource(client) const source = createSource(client)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar).toBeDefined() expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([]) expect(calendar!.inProgress).toEqual([])
@@ -327,8 +320,8 @@ describe("CalDavSource.fetchContext", () => {
const source = createSource(client) const source = createSource(client)
// 14:30 is during the 14:00-15:00 event // 14:30 is during the 14:00-15:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(1) expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup") expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
@@ -342,8 +335,8 @@ describe("CalDavSource.fetchContext", () => {
const source = createSource(client) const source = createSource(client)
// 12:00 is before the 14:00 event // 12:00 is before the 14:00 event
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull() expect(calendar!.nextEvent).not.toBeNull()
@@ -357,8 +350,8 @@ describe("CalDavSource.fetchContext", () => {
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = createSource(client)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull() expect(calendar!.nextEvent).toBeNull()
@@ -376,8 +369,8 @@ describe("CalDavSource.fetchContext", () => {
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client) const source = createSource(client)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.todayEventCount).toBe(2) expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true) expect(calendar!.hasTodayEvents).toBe(true)

View File

@@ -1,13 +1,13 @@
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 { DAVClient } from "tsdav" import { DAVClient } from "tsdav"
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts" import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
import { CalDavEventStatus } from "./types.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts" import { parseICalEvents } from "./ical-parser.ts"
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
// -- Source options -- // -- Source options --
@@ -93,20 +93,17 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
throw new UnknownActionError(actionId) throw new UnknownActionError(actionId)
} }
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> { async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchEvents(context) const events = await this.fetchEvents(context)
if (events.length === 0) { if (events.length === 0) {
return [ return {
[ [CalDavCalendarKey]: {
CalDavCalendarKey,
{
inProgress: [], inProgress: [],
nextEvent: null, nextEvent: null,
hasTodayEvents: false, hasTodayEvents: false,
todayEventCount: 0, todayEventCount: 0,
}, },
], }
]
} }
const now = context.time const now = context.time
@@ -124,7 +121,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
todayEventCount: events.length, todayEventCount: events.length,
} }
return [[CalDavCalendarKey, calendarContext]] return { [CalDavCalendarKey]: calendarContext }
} }
async fetchItems(context: Context): Promise<CalDavFeedItem[]> { async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
@@ -343,7 +340,7 @@ export function computeSignals(
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem { function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return { return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: CalDavFeedItemType.Event, type: "caldav-event",
timestamp: now, timestamp: now,
data: event, data: event,
signals: computeSignals(event, now, timeZone), signals: computeSignals(event, now, timeZone),

View File

@@ -21,4 +21,4 @@ export interface CalendarContext {
todayEventCount: number todayEventCount: number
} }
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar") export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")

View File

@@ -5,7 +5,6 @@ export {
AttendeeRole, AttendeeRole,
AttendeeStatus, AttendeeStatus,
CalDavEventStatus, CalDavEventStatus,
CalDavFeedItemType,
type CalDavAlarm, type CalDavAlarm,
type CalDavAttendee, type CalDavAttendee,
type CalDavDAVCalendar, type CalDavDAVCalendar,

View File

@@ -64,17 +64,9 @@ export interface CalDavEventData extends Record<string, unknown> {
recurrenceId: string | null recurrenceId: string | null
} }
// -- Feed item type --
export const CalDavFeedItemType = {
Event: "caldav-event",
} as const
export type CalDavFeedItemType = (typeof CalDavFeedItemType)[keyof typeof CalDavFeedItemType]
// -- Feed item -- // -- Feed item --
export type CalDavFeedItem = FeedItem<typeof CalDavFeedItemType.Event, CalDavEventData> export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
// -- DAV client interface -- // -- DAV client interface --

View File

@@ -10,4 +10,4 @@ export interface NextEvent {
location: string | null 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" import type { CalendarEventData } from "./types"
export const CalendarFeedItemType = { export const CalendarFeedItemType = {
Event: "calendar-event", event: "calendar-event",
AllDay: "calendar-all-day", allDay: "calendar-all-day",
} as const } as const
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType] export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
export interface CalendarEventFeedItem extends FeedItem< export interface CalendarEventFeedItem extends FeedItem<
typeof CalendarFeedItemType.Event, typeof CalendarFeedItemType.event,
CalendarEventData CalendarEventData
> {} > {}
export interface CalendarAllDayFeedItem extends FeedItem< export interface CalendarAllDayFeedItem extends FeedItem<
typeof CalendarFeedItemType.AllDay, typeof CalendarFeedItemType.allDay,
CalendarEventData 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 { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types" import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json" import fixture from "../fixtures/events.json"
import { NextEventKey, type NextEvent } from "./calendar-context" import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items" import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source" import { GoogleCalendarSource } from "./google-calendar-source"
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
} }
function createContext(time?: Date): Context { function createContext(time?: Date): Context {
return new Context(time ?? NOW) return { time: time ?? NOW }
} }
describe("GoogleCalendarSource", () => { describe("GoogleCalendarSource", () => {
@@ -69,7 +69,7 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) 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) expect(timedItems.length).toBe(4)
}) })
@@ -77,7 +77,7 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext()) 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) expect(allDayItems.length).toBe(1)
}) })
@@ -229,16 +229,15 @@ describe("GoogleCalendarSource", () => {
test("returns next upcoming timed event (not ongoing)", async () => { test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() }) const source = new GoogleCalendarSource({ client: defaultMockClient() })
const entries = await source.fetchContext(createContext()) const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull() expect(result).not.toBeNull()
expect(entries).toHaveLength(1) const nextEvent = contextValue(result! as Context, NextEventKey)
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent] expect(nextEvent).toBeDefined()
expect(key).toEqual(NextEventKey)
// evt-soon starts at 10:10, which is the nearest future timed event // evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent.title).toBe("1:1 with Manager") expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent.minutesUntilStart).toBe(10) expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent.location).toBeNull() expect(nextEvent!.location).toBeNull()
}) })
test("includes location when available", async () => { test("includes location when available", async () => {
@@ -256,11 +255,12 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({ const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }), client: createMockClient({ primary: events }),
}) })
const entries = await source.fetchContext(createContext()) const result = await source.fetchContext(createContext())
expect(entries).not.toBeNull() expect(result).not.toBeNull()
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent] const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent.location).toBe("123 Main St") expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
}) })
test("skips ongoing events for next-event context", async () => { 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 { import type {
ApiCalendarEvent, ApiCalendarEvent,
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
* .register(calendarSource) * .register(calendarSource)
* *
* // Access next-event context in downstream sources * // Access next-event context in downstream sources
* const next = context.get(NextEventKey) * const next = contextValue(context, NextEventKey)
* if (next && next.minutesUntilStart < 15) { * if (next && next.minutesUntilStart < 15) {
* // remind user * // remind user
* } * }
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
throw new UnknownActionError(actionId) 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 events = await this.fetchAllEvents(context.time)
const now = context.time.getTime() const now = context.time.getTime()
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
location: nextTimedEvent.location, location: nextTimedEvent.location,
} }
return [[NextEventKey, nextEvent]] return { [NextEventKey]: nextEvent }
} }
async fetchItems(context: Context): Promise<CalendarFeedItem[]> { async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
@@ -209,7 +209,7 @@ function createFeedItem(
nowMs: number, nowMs: number,
lookaheadMs: number, lookaheadMs: number,
): CalendarFeedItem { ): CalendarFeedItem {
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return { return {
id: `calendar-${event.calendarId}-${event.eventId}`, id: `calendar-${event.calendarId}-${event.eventId}`,

View File

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

View File

@@ -1,8 +1,6 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import type { Location } from "./types.ts" import { LocationKey, LocationSource, type Location } from "./location-source.ts"
import { LocationKey, LocationSource } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location { function createLocation(overrides: Partial<Location> = {}): Location {
return { return {
@@ -41,8 +39,8 @@ describe("LocationSource", () => {
const location = createLocation() const location = createLocation()
source.pushLocation(location) source.pushLocation(location)
const entries = await source.fetchContext() const context = await source.fetchContext()
expect(entries).toEqual([[LocationKey, location]]) expect(context).toEqual({ [LocationKey]: location })
}) })
}) })
@@ -67,7 +65,7 @@ describe("LocationSource", () => {
source.pushLocation(location) source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1) 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 { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts" 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. * A FeedSource that provides location context.
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
private readonly historySize: number private readonly historySize: number
private locations: Location[] = [] private locations: Location[] = []
private listeners = new Set<(entries: readonly ContextEntry[]) => void>() private listeners = new Set<(update: Partial<Context>) => void>()
constructor(options: LocationSourceOptions = {}) { constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1 this.historySize = options.historySize ?? 1
@@ -59,9 +59,8 @@ export class LocationSource implements FeedSource {
if (this.locations.length > this.historySize) { if (this.locations.length > this.historySize) {
this.locations.shift() this.locations.shift()
} }
const entries: readonly ContextEntry[] = [[LocationKey, location]]
this.listeners.forEach((listener) => { this.listeners.forEach((listener) => {
listener(entries) listener({ [LocationKey]: location })
}) })
} }
@@ -79,16 +78,16 @@ export class LocationSource implements FeedSource {
return this.locations return this.locations
} }
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void { onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
this.listeners.add(callback) this.listeners.add(callback)
return () => { return () => {
this.listeners.delete(callback) this.listeners.delete(callback)
} }
} }
async fetchContext(): Promise<readonly ContextEntry[] | null> { async fetchContext(): Promise<Partial<Context> | null> {
if (this.lastLocation) { if (this.lastLocation) {
return [[LocationKey, this.lastLocation]] return { [LocationKey]: this.lastLocation }
} }
return null return null
} }

View File

@@ -1,13 +1,12 @@
export { TflSource } from "./tfl-source.ts" export { TflSource } from "./tfl-source.ts"
export { TflApi } from "./tfl-api.ts" export { TflApi } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts" export type { TflLineId } from "./tfl-api.ts"
export { export type {
TflFeedItemType, ITflApi,
type ITflApi, StationLocation,
type StationLocation, TflAlertData,
type TflAlertData, TflAlertFeedItem,
type TflAlertFeedItem, TflAlertSeverity,
type TflAlertSeverity, TflLineStatus,
type TflLineStatus, TflSourceOptions,
type TflSourceOptions,
} from "./types.ts" } 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 { LocationKey, type Location } from "@aris/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -80,9 +81,9 @@ class FixtureTflApi implements ITflApi {
} }
function createContext(location?: Location): Context { 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) { if (location) {
ctx.set([[LocationKey, location]]) ctx[LocationKey] = location
} }
return ctx 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 { LocationKey } from "@aris/source-location"
import { type } from "arktype" import { type } from "arktype"
@@ -15,7 +15,6 @@ import type {
} from "./types.ts" } from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts" import { TflApi, lineId } from "./tfl-api.ts"
import { TflFeedItemType } from "./types.ts"
const setLinesInput = lineId.array() 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 return null
} }
@@ -129,7 +128,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.client.fetchStations(), this.client.fetchStations(),
]) ])
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => { const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location const closestStationDistance = location
@@ -151,7 +150,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
return { return {
id: `tfl-alert-${status.lineId}-${status.severity}`, id: `tfl-alert-${status.lineId}-${status.severity}`,
type: TflFeedItemType.Alert, type: "tfl-alert",
timestamp: context.time, timestamp: context.time,
data, data,
signals, signals,

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
export { WeatherKey, type Weather } from "./weather-context" 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 { export {
WeatherFeedItemType, WeatherFeedItemType,
type WeatherFeedItemType as WeatherFeedItemTypeType,
type WeatherFeedItem, type WeatherFeedItem,
type CurrentWeatherFeedItem, type CurrentWeatherFeedItem,
type CurrentWeatherData, type CurrentWeatherData,
@@ -21,6 +27,11 @@ export {
Certainty, Certainty,
PrecipitationType, PrecipitationType,
DefaultWeatherKitClient, DefaultWeatherKitClient,
type ConditionCode as ConditionCodeType,
type Severity as SeverityType,
type Urgency as UrgencyType,
type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType,
type WeatherKitClient, type WeatherKitClient,
type WeatherKitCredentials, type WeatherKitCredentials,
type WeatherKitQueryOptions, type WeatherKitQueryOptions,

View File

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

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 { LocationKey } from "@aris/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
* }) * })
* *
* // Access weather context in downstream sources * // Access weather context in downstream sources
* const weather = context.get(WeatherKey) * const weather = contextValue(context, WeatherKey)
* if (weather?.condition === "Rain") { * if (weather?.condition === "Rain") {
* // suggest umbrella * // suggest umbrella
* } * }
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
throw new UnknownActionError(actionId) throw new UnknownActionError(actionId)
} }
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> { async fetchContext(context: Context): Promise<Partial<Context> | null> {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) { if (!location) {
return null return null
} }
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
daylight: response.currentWeather.daylight, daylight: response.currentWeather.daylight,
} }
return [[WeatherKey, weather]] return { [WeatherKey]: weather }
} }
async fetchItems(context: Context): Promise<WeatherFeedItem[]> { async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
const location = context.get(LocationKey) const location = contextValue(context, LocationKey)
if (!location) { if (!location) {
return [] return []
} }
@@ -291,7 +291,7 @@ function createCurrentWeatherFeedItem(
return { return {
id: `weather-current-${timestamp.getTime()}`, id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current, type: WeatherFeedItemType.current,
timestamp, timestamp,
data: { data: {
conditionCode: current.conditionCode, conditionCode: current.conditionCode,
@@ -325,7 +325,7 @@ function createHourlyWeatherFeedItem(
return { return {
id: `weather-hourly-${timestamp.getTime()}-${index}`, id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Hourly, type: WeatherFeedItemType.hourly,
timestamp, timestamp,
data: { data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
@@ -359,7 +359,7 @@ function createDailyWeatherFeedItem(
return { return {
id: `weather-daily-${timestamp.getTime()}-${index}`, id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.Daily, type: WeatherFeedItemType.daily,
timestamp, timestamp,
data: { data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
@@ -386,7 +386,7 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
return { return {
id: `weather-alert-${alert.id}`, id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.Alert, type: WeatherFeedItemType.alert,
timestamp, timestamp,
data: { data: {
alertId: alert.id, alertId: alert.id,