Compare commits

..

3 Commits

Author SHA1 Message Date
bb09de2da6 fix: timezone-aware signals, low-priority cancelled events
- computeSignals uses startOfDay(timeZone) for 'later today' boundary
- Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 16:08:07 +00:00
e220ccc392 fix: deduplicate concurrent fetchEvents calls
Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:53:36 +00:00
851e6af951 feat: add generic CalDAV calendar data source
Add @aris/source-caldav package that fetches calendar events from any
CalDAV server via tsdav + ical.js.

- Supports Basic auth and OAuth via explicit authMethod discriminant
- serverUrl provided at construction time, not hardcoded
- Optional timeZone for correct local day boundaries
- Credentials cleared from memory after client login
- Failed calendar fetches logged, not silently dropped
- Login promise cached with retry on failure

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:35:22 +00:00
6 changed files with 15 additions and 818 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,7 +1,6 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source"
export interface SourceError {
@@ -13,8 +12,6 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
context: Context
items: TItem[]
errors: SourceError[]
/** Item groups produced by post-processors */
groupedItems?: ItemGroup[]
}
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
@@ -69,7 +66,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = []
private started = false
private postProcessors: FeedPostProcessor[] = []
private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null
@@ -112,23 +108,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this
}
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
*/
registerPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors.push(processor)
return this
}
/**
* Unregisters a post-processor by reference.
*/
unregisterPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
return this
}
/**
* Refreshes the feed by running all sources in dependency order.
* Calls fetchContext() then fetchItems() on each source.
@@ -173,18 +152,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context
const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], context, errors)
const result: FeedResult<TItems> = {
context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
}
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
this.updateCache(result)
return result
@@ -292,72 +260,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return actions
}
private async applyPostProcessors(
items: TItems[],
context: Context,
errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items
const allGroupedItems: ItemGroup[] = []
const allErrors = [...errors]
const boostScores = new Map<string, number>()
for (const processor of this.postProcessors) {
const snapshot = currentItems
try {
const enhancement = await processor(currentItems, context)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
// Additional items are merged untyped — this is intentional. The
// processor contract is "FeedItem in, FeedItem out"; type narrowing
// is the caller's responsibility when consuming FeedResult.
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
}
if (enhancement.suppress?.length) {
const suppressSet = new Set(enhancement.suppress)
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
}
if (enhancement.groupedItems?.length) {
allGroupedItems.push(...enhancement.groupedItems)
}
if (enhancement.boost) {
for (const [id, score] of Object.entries(enhancement.boost)) {
boostScores.set(id, (boostScores.get(id) ?? 0) + score)
}
}
} catch (err) {
const sourceId = processor.name || "anonymous"
allErrors.push({
sourceId,
error: err instanceof Error ? err : new Error(String(err)),
})
currentItems = snapshot
}
}
// Apply boost reordering: positive-boost first (desc), then zero, then negative (desc).
// Stable sort within each tier preserves original relative order.
if (boostScores.size > 0) {
currentItems = applyBoostOrder(currentItems, boostScores)
}
// Remove stale item IDs from groups and drop empty groups
const itemIds = new Set(currentItems.map((item) => item.id))
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
const ids = group.itemIds.filter((id) => itemIds.has(id))
if (ids.length > 0) {
acc.push({ ...group, itemIds: ids })
}
return acc
}, [])
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
}
private ensureGraph(): SourceGraph {
if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values()))
@@ -409,17 +311,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], this.context, errors)
const result: FeedResult<TItems> = {
context: this.context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
items: items as TItems[],
errors,
}
this.updateCache(result)
@@ -500,47 +395,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function applyBoostOrder<T extends FeedItem>(items: T[], boostScores: Map<string, number>): T[] {
const positive: T[] = []
const neutral: T[] = []
const negative: T[] = []
for (const item of items) {
const raw = boostScores.get(item.id)
if (raw === undefined || raw === 0) {
neutral.push(item)
} else {
const clamped = clamp(raw, -1, 1)
if (clamped > 0) {
positive.push(item)
} else if (clamped < 0) {
negative.push(item)
} else {
neutral.push(item)
}
}
}
// Sort positive descending by boost, negative descending (least negative first, most negative last)
positive.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
negative.sort((a, b) => {
const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1)
const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1)
return bScore - aScore
})
return [...positive, ...neutral, ...negative]
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {

View File

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

View File

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

View File

@@ -13,9 +13,6 @@ export { TimeRelevance } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"

View File

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