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 { 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

@@ -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,9 @@
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
@@ -67,7 +65,7 @@ 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
@@ -140,14 +138,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({
@@ -179,7 +177,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], context, errors)
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = {
context,
@@ -215,8 +213,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,
)
@@ -296,7 +294,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private async applyPostProcessors(
items: TItems[],
context: Context,
errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items
@@ -307,7 +304,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const processor of this.postProcessors) {
const snapshot = currentItems
try {
const enhancement = await processor(currentItems, context)
const enhancement = await processor(currentItems)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
@@ -367,9 +364,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 +380,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
@@ -416,7 +412,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = {
context: this.context,

View File

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

View File

@@ -1,4 +1,3 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
export interface ItemGroup {
@@ -23,4 +22,4 @@ export interface FeedEnhancement {
* 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>
export type FeedPostProcessor = (items: FeedItem[]) => 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,6 +1,6 @@
// 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"

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

@@ -5,8 +5,6 @@
* bun run test-live.ts
*/
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")
@@ -29,7 +27,7 @@ const source = new CalDavSource({
lookAheadDays,
})
const context = new Context()
const context = { time: new Date() }
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 { readFileSync } from "node:fs"
import { join } from "node:path"
@@ -13,21 +13,14 @@ import type {
} from "./types.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 {
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
return { time }
}
class MockDAVClient implements CalDavDAVClient {
@@ -309,8 +302,8 @@ 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)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([])
@@ -327,8 +320,8 @@ describe("CalDavSource.fetchContext", () => {
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)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
@@ -342,8 +335,8 @@ describe("CalDavSource.fetchContext", () => {
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)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull()
@@ -357,8 +350,8 @@ describe("CalDavSource.fetchContext", () => {
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)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull()
@@ -376,8 +369,8 @@ describe("CalDavSource.fetchContext", () => {
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)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.todayEventCount).toBe(2)
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 type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
import { CalDavEventStatus } 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 --
@@ -93,20 +93,17 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
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)
if (events.length === 0) {
return [
[
CalDavCalendarKey,
{
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
],
]
return {
[CalDavCalendarKey]: {
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
}
}
const now = context.time
@@ -124,7 +121,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
todayEventCount: events.length,
}
return [[CalDavCalendarKey, calendarContext]]
return { [CalDavCalendarKey]: calendarContext }
}
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
@@ -343,7 +340,7 @@ export function computeSignals(
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: CalDavFeedItemType.Event,
type: "caldav-event",
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),

View File

@@ -21,4 +21,4 @@ export interface CalendarContext {
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,
AttendeeStatus,
CalDavEventStatus,
CalDavFeedItemType,
type CalDavAlarm,
type CalDavAttendee,
type CalDavDAVCalendar,

View File

@@ -64,17 +64,9 @@ export interface CalDavEventData extends Record<string, unknown> {
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>
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
// -- DAV client interface --

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

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

@@ -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)
})
@@ -180,12 +177,12 @@ describe("WeatherSource", () => {
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,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 { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
@@ -86,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
* }
@@ -119,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
}
@@ -147,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 []
}
@@ -291,7 +291,7 @@ function createCurrentWeatherFeedItem(
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.Current,
type: WeatherFeedItemType.current,
timestamp,
data: {
conditionCode: current.conditionCode,
@@ -325,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),
@@ -359,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),
@@ -386,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,