Compare commits

..

7 Commits

27 changed files with 847 additions and 490 deletions

View File

@@ -15,20 +15,8 @@ interface AuthSession {
}
}
interface ProposedAction {
id: string
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
requiresConfirmation: true
createdAt: string
}
interface QueryResponse {
message: string
proposedActions: ProposedAction[]
}
interface QueryToolDefinition {
@@ -187,7 +175,6 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string)
}
console.log(`\nagent> ${data.message || "(no message)"}`)
printProposedActions(data.proposedActions)
console.log("")
}
@@ -366,22 +353,6 @@ function printHelp(): void {
console.log(" /quit Exit\n")
}
function printProposedActions(actions: ProposedAction[]): void {
if (actions.length === 0) return
console.log("\nProposed actions:")
for (const action of actions) {
console.log(`- ${action.title} (${action.id})`)
console.log(` ${action.description}`)
if (action.sourceId || action.actionId) {
console.log(` source=${action.sourceId ?? "-"} action=${action.actionId ?? "-"}`)
}
if (action.params !== undefined) {
console.log(` params=${JSON.stringify(action.params)}`)
}
}
}
function askRequired(
label: string,
defaultValue?: string,
@@ -579,9 +550,7 @@ function isAuthSession(value: unknown): value is AuthSession {
function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false
if (typeof value.message !== "string") return false
if (!Array.isArray(value.proposedActions)) return false
return value.proposedActions.every(isProposedAction)
return typeof value.message === "string"
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
@@ -616,20 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri
)
}
function isProposedAction(value: unknown): value is ProposedAction {
if (!isJsonObject(value)) return false
return (
typeof value.id === "string" &&
typeof value.title === "string" &&
typeof value.description === "string" &&
(value.sourceId === undefined || typeof value.sourceId === "string") &&
(value.actionId === undefined || typeof value.actionId === "string") &&
value.requiresConfirmation === true &&
typeof value.createdAt === "string"
)
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View File

@@ -12,8 +12,6 @@ BETTER_AUTH_URL=http://localhost:3000
# OpenRouter (LLM feed enhancement)
OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY=

View File

@@ -57,6 +57,28 @@ describe("query debug tools", () => {
},
])
})
test("executes source action directly", async () => {
const tools = createTestDebugTools()
const params = { title: "Buy tea" }
const result = await tools.execute("user-1", "freya_execute_action", {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
})
expect(result).toEqual({
ok: true,
sourceId: "freya.reminders",
actionId: "create-reminder",
result: {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
},
})
})
})
function createTestDebugTools() {
@@ -109,6 +131,16 @@ function createTestDebugTools() {
async listActions(sourceId: string) {
return actions[sourceId] ?? {}
},
async executeAction(sourceId: string, actionId: string, params: unknown) {
const sourceActions = actions[sourceId]
if (!sourceActions) {
throw new Error(`Source not found: ${sourceId}`)
}
if (!(actionId in sourceActions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return { sourceId, actionId, params }
},
},
hasSource(sourceId: string) {
return sourceId in actions

View File

@@ -1,7 +1,6 @@
import { contextKey, type ContextKeyPart } from "@freya/core"
import type { UserSessionManager } from "../session/index.ts"
import type { ProposedAction } from "./query-agent.ts"
type ToolParams = Record<string, unknown>
@@ -23,7 +22,7 @@ const FreyaGetContextTool = "freya_get_context"
const FreyaListContextTool = "freya_list_context"
const FreyaGetSourceDataTool = "freya_get_source_data"
const FreyaGetFeedItemTool = "freya_get_feed_item"
const FreyaProposeActionTool = "freya_propose_action"
const FreyaExecuteActionTool = "freya_execute_action"
export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools {
return new DefaultQueryDebugTools(sessionManager)
@@ -86,14 +85,12 @@ class DefaultQueryDebugTools implements QueryDebugTools {
},
},
{
name: FreyaProposeActionTool,
label: "Propose FREYA Action",
description: "Create a proposed action object without executing it.",
name: FreyaExecuteActionTool,
label: "Execute FREYA Action",
description: "Execute an available source action immediately.",
parameters: {
title: "string",
description: "string",
sourceId: "string?",
actionId: "string?",
sourceId: "string",
actionId: "string",
params: "unknown?",
},
},
@@ -114,8 +111,8 @@ class DefaultQueryDebugTools implements QueryDebugTools {
return this.listContext(userId)
case FreyaGetSourceDataTool:
return this.getSourceData(userId, expectToolParams(params, ["sourceId"]))
case FreyaProposeActionTool:
return proposeAction(expectToolParams(params, ["title", "description"]))
case FreyaExecuteActionTool:
return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"]))
default:
throw new Error(`Unknown debug tool: ${toolName}`)
}
@@ -322,27 +319,20 @@ class DefaultQueryDebugTools implements QueryDebugTools {
errors,
}
}
}
function proposeAction(params: ToolParams): unknown {
const sourceId = optionalString(params, "sourceId")
const actionId = optionalString(params, "actionId")
const action: ProposedAction = {
id: crypto.randomUUID(),
title: expectString(params, "title"),
description: expectString(params, "description"),
requiresConfirmation: true,
createdAt: new Date().toISOString(),
...(sourceId ? { sourceId } : {}),
...(actionId ? { actionId } : {}),
...("params" in params ? { params: params.params } : {}),
}
private async executeAction(userId: string, params: ToolParams): Promise<unknown> {
const sourceId = expectString(params, "sourceId")
const actionId = expectString(params, "actionId")
const actionParams = "params" in params ? params.params : undefined
const userSession = await this.sessionManager.getOrCreate(userId)
const result = await userSession.engine.executeAction(sourceId, actionId, actionParams)
return {
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
proposedAction: action,
sourceId,
actionId,
result: result ?? null,
}
}
}

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
@@ -24,8 +25,6 @@ class FakeQueryAgent implements QueryAgent {
}
}
disposeUser(): void {}
dispose(): void {}
}
@@ -52,8 +51,14 @@ class FakeDebugTools implements QueryDebugTools {
function buildTestApp(queryAgent: QueryAgent, userId?: string) {
const app = new Hono()
const sessionManager = {
async getOrCreate() {
return { agent: queryAgent }
},
} as unknown as UserSessionManager
registerAgentHttpHandlers(app, {
queryAgent,
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
@@ -80,21 +85,10 @@ describe("POST /api/agent", () => {
expect(res.status).toBe(401)
})
test("collects text deltas and proposed actions", async () => {
const action: ProposedAction = {
id: "proposal-1",
title: "Update commute line",
description: "Set the user's commute line to Victoria.",
sourceId: "freya.tfl",
actionId: "set-lines-of-interest",
params: ["victoria"],
requiresConfirmation: true,
createdAt: "2026-06-12T12:00:00.000Z",
}
test("collects text deltas", async () => {
const agent = new FakeQueryAgent([
{ type: "text_delta", text: "You should " },
{ type: "text_delta", text: "leave at 8:30." },
{ type: "action_proposed", action },
{ type: "done" },
])
const app = buildTestApp(agent, "user-1")
@@ -112,10 +106,8 @@ describe("POST /api/agent", () => {
const body = (await res.json()) as {
message: string
proposedActions: ProposedAction[]
}
expect(body.message).toBe("You should leave at 8:30.")
expect(body.proposedActions).toEqual([action])
})
test("returns 400 for invalid body", async () => {

View File

@@ -4,14 +4,14 @@ import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import type { QueryAgent } from "./query-agent.ts"
import { collectQueryAgentResponse, QueryAgentError } from "./query-agent.ts"
type Env = {
Variables: {
queryAgent: QueryAgent
sessionManager: UserSessionManager
}
}
@@ -22,7 +22,7 @@ type DebugEnv = {
}
interface AgentHttpHandlersDeps {
queryAgent: QueryAgent
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
@@ -39,10 +39,10 @@ const AgentAskRequestBody = type({
export function registerAgentHttpHandlers(
app: Hono,
{ queryAgent, authSessionMiddleware }: AgentHttpHandlersDeps,
{ sessionManager, authSessionMiddleware }: AgentHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("queryAgent", queryAgent)
c.set("sessionManager", sessionManager)
await next()
})
@@ -76,11 +76,11 @@ async function handleAgentAsk(c: Context<Env>) {
}
const user = c.get("user")!
const queryAgent = c.get("queryAgent")
const sessionManager = c.get("sessionManager")
try {
const response = await collectQueryAgentResponse(queryAgent, {
userId: user.id,
const session = await sessionManager.getOrCreate(user.id)
const response = await collectQueryAgentResponse(session.agent, {
message: parsed.message,
})
return c.json(response)

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession {
@@ -11,6 +11,8 @@ interface FakePiSession {
let createAgentSessionCalls = 0
let createAgentSessionOptions: unknown
let runtimeApiKeyCalls: Array<{ provider: string; apiKey: string }> = []
let modelFindCalls: Array<{ provider: string; modelId: string }> = []
let promptCalls = 0
let unsubscribeCalls = 0
let sessionListeners: Array<(event: unknown) => void> = []
@@ -53,7 +55,9 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
AuthStorage: {
inMemory() {
return {
setRuntimeApiKey(_provider: string, _apiKey: string): void {},
setRuntimeApiKey(provider: string, apiKey: string): void {
runtimeApiKeyCalls.push({ provider, apiKey })
},
}
},
},
@@ -73,7 +77,8 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
ModelRegistry: {
inMemory(_authStorage: unknown) {
return {
find(_provider: string, _modelId: string): unknown {
find(provider: string, modelId: string): unknown {
modelFindCalls.push({ provider, modelId })
return { id: "mock-model" }
},
}
@@ -94,6 +99,8 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
beforeEach(() => {
createAgentSessionCalls = 0
createAgentSessionOptions = undefined
runtimeApiKeyCalls = []
modelFindCalls = []
promptCalls = 0
unsubscribeCalls = 0
sessionListeners = []
@@ -124,16 +131,15 @@ describe("PiQueryAgent", () => {
test("rejects a concurrent first query while the Pi session is being created", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(),
modelProvider: "mock",
modelId: "mock-model",
userId: "user-1",
toolbox: createStubToolbox(),
apiKey: "test-api-key",
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
})
const firstEvents = collectEvents(
agent.ask({
userId: "user-1",
message: "first",
}),
)
@@ -142,7 +148,6 @@ describe("PiQueryAgent", () => {
const secondEvents = await collectEvents(
agent.ask({
userId: "user-1",
message: "second",
}),
)
@@ -154,6 +159,8 @@ describe("PiQueryAgent", () => {
},
])
expect(createAgentSessionCalls).toBe(1)
expect(runtimeApiKeyCalls).toEqual([{ provider: "openrouter", apiKey: "test-api-key" }])
expect(modelFindCalls).toEqual([{ provider: "openrouter", modelId: "z-ai/glm-4.7-flash" }])
expect(promptCalls).toBe(0)
releaseSessionCreation()
@@ -175,9 +182,8 @@ describe("PiQueryAgent", () => {
test("surfaces Pi message_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(),
modelProvider: "mock",
modelId: "mock-model",
userId: "user-1",
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
})
@@ -195,7 +201,6 @@ describe("PiQueryAgent", () => {
const events = collectEvents(
agent.ask({
userId: "user-1",
message: "hello",
}),
)
@@ -214,9 +219,8 @@ describe("PiQueryAgent", () => {
test("surfaces Pi agent_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
sessionManager: createStubSessionManager(),
modelProvider: "mock",
modelId: "mock-model",
userId: "user-1",
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
})
@@ -236,7 +240,6 @@ describe("PiQueryAgent", () => {
const events = collectEvents(
agent.ask({
userId: "user-1",
message: "hello",
}),
)
@@ -261,12 +264,30 @@ async function collectEvents(events: AsyncIterable<QueryAgentEvent>): Promise<Qu
return result
}
function createStubSessionManager(): UserSessionManager {
function createStubToolbox(): QueryAgentToolbox {
return {
async getOrCreate(): Promise<never> {
async listSources(): Promise<never> {
throw new Error("not used")
},
} as unknown as UserSessionManager
async getContext(): Promise<never> {
throw new Error("not used")
},
async getFeedItem(): Promise<never> {
throw new Error("not used")
},
async queryContext(): Promise<never> {
throw new Error("not used")
},
async listContext(): Promise<never> {
throw new Error("not used")
},
async getSourceData(): Promise<never> {
throw new Error("not used")
},
async executeAction(): Promise<never> {
throw new Error("not used")
},
}
}
function isRecord(value: unknown): value is Record<string, unknown> {

View File

@@ -9,8 +9,8 @@ import {
} from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { UserSessionManager } from "../session/index.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
import defaultSystemPrompt from "./prompts/system.txt"
@@ -22,43 +22,37 @@ type PiAgentMessage = PiMessageEndEvent["message"]
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
export interface PiQueryAgentConfig {
sessionManager: UserSessionManager
modelProvider: string
modelId: string
userId: string
toolbox: QueryAgentToolbox
apiKey?: string
cwd?: string
systemPrompt?: string
clock?: () => Date
}
interface ActiveRun {
proposedActions: ProposedAction[]
}
const MODEL_PROVIDER = "openrouter"
const MODEL_ID = "z-ai/glm-4.7-flash"
export class PiQueryAgent implements QueryAgent {
private readonly sessionManager: UserSessionManager
private readonly userId: string
private readonly toolbox: QueryAgentToolbox
private readonly cwd: string
private readonly systemPrompt: string
private readonly clock: () => Date
private readonly modelProvider: string
private readonly modelId: string
private readonly apiKey: string | undefined
private readonly sessions = new Map<string, PiSession>()
private readonly pendingSessions = new Map<string, Promise<PiSession>>()
private readonly activeRuns = new Map<string, ActiveRun>()
private session: PiSession | null = null
private pendingSession: Promise<PiSession> | null = null
private activeRun: symbol | null = null
private disposed = false
constructor(config: PiQueryAgentConfig) {
this.sessionManager = config.sessionManager
this.modelProvider = config.modelProvider
this.modelId = config.modelId
this.userId = config.userId
this.toolbox = config.toolbox
this.apiKey = config.apiKey
this.cwd = config.cwd ?? tmpdir()
this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt
this.clock = config.clock ?? (() => new Date())
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
if (this.activeRuns.has(input.userId)) {
if (this.activeRun) {
yield {
type: "error",
message: "A query is already running for this user",
@@ -66,14 +60,14 @@ export class PiQueryAgent implements QueryAgent {
return
}
const run: ActiveRun = { proposedActions: [] }
this.activeRuns.set(input.userId, run)
const run = Symbol(this.userId)
this.activeRun = run
let session: PiSession
try {
session = await this.getOrCreateSession(input.userId)
session = await this.getOrCreateSession()
} catch (err) {
this.clearActiveRun(input.userId, run)
this.clearActiveRun(run)
yield {
type: "error",
message: `Failed to create query session: ${errorMessage(err)}`,
@@ -117,9 +111,6 @@ export class PiQueryAgent implements QueryAgent {
void this.runPrompt(session, input)
.then(() => {
if (runFailed) return
for (const action of run.proposedActions) {
pushRunEvent({ type: "action_proposed", action })
}
pushRunEvent({ type: "done" })
})
.catch((err: unknown) => {
@@ -127,7 +118,7 @@ export class PiQueryAgent implements QueryAgent {
})
.finally(() => {
unsubscribe()
this.clearActiveRun(input.userId, run)
this.clearActiveRun(run)
close()
})
@@ -144,62 +135,62 @@ export class PiQueryAgent implements QueryAgent {
}
}
disposeUser(userId: string): void {
const session = this.sessions.get(userId)
session?.dispose()
this.sessions.delete(userId)
this.pendingSessions.delete(userId)
this.activeRuns.delete(userId)
}
dispose(): void {
for (const session of this.sessions.values()) {
session.dispose()
}
this.sessions.clear()
this.pendingSessions.clear()
this.activeRuns.clear()
this.disposed = true
this.session?.dispose()
this.session = null
this.pendingSession = null
this.activeRun = null
}
private clearActiveRun(userId: string, run: ActiveRun): void {
if (this.activeRuns.get(userId) === run) {
this.activeRuns.delete(userId)
private clearActiveRun(run: symbol): void {
if (this.activeRun === run) {
this.activeRun = null
}
}
private async getOrCreateSession(userId: string): Promise<PiSession> {
const existing = this.sessions.get(userId)
if (existing) return existing
private async getOrCreateSession(): Promise<PiSession> {
if (this.disposed) {
throw new Error("Query agent is disposed")
}
const pending = this.pendingSessions.get(userId)
if (this.session) return this.session
const pending = this.pendingSession
if (pending) return pending
const promise = this.createSession(userId)
this.pendingSessions.set(userId, promise)
const promise = this.createSession()
this.pendingSession = promise
try {
const session = await promise
this.sessions.set(userId, session)
if (this.disposed) {
session.dispose()
throw new Error("Query agent is disposed")
}
this.session = session
return session
} finally {
this.pendingSessions.delete(userId)
if (this.pendingSession === promise) {
this.pendingSession = null
}
}
}
private async createSession(userId: string): Promise<PiSession> {
private async createSession(): Promise<PiSession> {
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: true },
retry: { enabled: true, maxRetries: 2 },
})
const authStorage = AuthStorage.inMemory()
if (this.apiKey) {
authStorage.setRuntimeApiKey(this.modelProvider, this.apiKey)
authStorage.setRuntimeApiKey(MODEL_PROVIDER, this.apiKey)
}
const modelRegistry = ModelRegistry.inMemory(authStorage)
const model = modelRegistry.find(this.modelProvider, this.modelId)
const model = modelRegistry.find(MODEL_PROVIDER, MODEL_ID)
if (!model) {
throw new Error(`Pi model not found: ${this.modelProvider}/${this.modelId}`)
throw new Error(`Pi model not found: ${MODEL_PROVIDER}/${MODEL_ID}`)
}
const { session } = await createAgentSession({
@@ -212,12 +203,7 @@ export class PiQueryAgent implements QueryAgent {
sessionManager: SessionManager.inMemory(this.cwd),
noTools: "builtin",
customTools: createFreyaAgentTools({
userId,
sessionManager: this.sessionManager,
clock: this.clock,
proposeAction: (action) => {
this.activeRuns.get(userId)?.proposedActions.push(action)
},
toolbox: this.toolbox,
}),
tools: [...FREYA_AGENT_TOOL_NAMES],
})

View File

@@ -1,5 +1,7 @@
<identity>
You are Freya. You are a digital companion created by Kenneth. His twitter is @kennethnym.
You have access to user data via the context graph. It stores the latest snapshot of all user data and context.
It reactively updates based on external events, such as, but not exclusively, when the user moves, when a new email arrives, when weather updates are available, and when transit alerts are issued.
</identity>
<action>
@@ -15,9 +17,9 @@ freya_list_context: when you need to inspect all current context graph entries.
freya_get_source_data: when you need current feed items, context entries, actions, or errors for a specific source ID.
freya_propose_action: when the user asks to change state or when you recommend a concrete action that should be confirmed first. This tool only proposes an action. It does not execute the action.
freya_execute_action: when the user asks you to perform an available source action, or when the source action is non-mutating and tool-like. This executes immediately.
if you need more information to answer user's query, call freya_propose_action with freya.web-search source id.
If you need more information to answer user's query, call freya_execute_action with sourceId "freya.web-search", actionId "search", and params containing query and numResults, for example {"query":"latest relevant information","numResults":5}.
</action>
<behavior>

View File

@@ -0,0 +1,93 @@
import type { ContextKeyPart } from "@freya/core"
export interface QueryAgentToolResult {
content: Array<{ type: "text"; text: string }>
details: Record<string, unknown>
}
/**
* Implementation boundary for FREYA query-agent tools.
*
* The Pi-facing tool definitions in `tools.ts` should stay thin: they declare
* schemas, validate and narrow raw model-provided parameters, then delegate to
* this toolbox. Concrete implementations own the actual data gathering,
* source/action lookups, result shaping, and any session-specific behavior.
*/
export interface QueryAgentToolbox {
/**
* Summarizes every source currently visible to the user's session.
*
* Implementations should refresh or read the current feed as needed, then
* return a compact source inventory including feed item counts, context
* entry counts, available action IDs/descriptions, and source errors. This
* is the broad discovery tool an agent can use before deciding which more
* targeted tool call to make.
*/
listSources(): Promise<QueryAgentToolResult>
/**
* Reads context entries from the current FREYA context graph.
*
* `key` is a tuple-style context key. With `match: "exact"`, the implementation
* should return only the value at that exact key and indicate whether it was
* found. With `match: "prefix"`, it should return all entries whose keys
* begin with the provided key parts, plus a count. Implementations may refresh
* the feed first so the context reflects the latest source data.
*/
getContext(key: ContextKeyPart[], match: "exact" | "prefix"): Promise<QueryAgentToolResult>
/**
* Reads one feed item by ID and includes source-local diagnostics.
*
* Implementations should search the current feed for `feedItemId`. When found,
* the result should include the item plus related context entries, source
* action summaries, and source errors. When missing, the result should clearly
* report `found: false` and return `item: null`.
*/
getFeedItem(feedItemId: string): Promise<QueryAgentToolResult>
/**
* Returns the broad context bundle needed to answer a natural-language query.
*
* `question` is included in the result for traceability. If `feedItemId` is
* provided, implementations should also include the matching selected item
* when present. The result should expose the current feed items, context graph
* entries, available source actions, and source errors so the agent can
* synthesize an answer from the user's personal data.
*/
queryContext(question: string, feedItemId?: string): Promise<QueryAgentToolResult>
/**
* Lists every current context graph entry.
*
* This is a lower-level inspection tool than `queryContext`: it should return
* all context entries and a count, without feed items or action summaries.
* Implementations may refresh the feed first to ensure source-provided
* context has been materialized.
*/
listContext(): Promise<QueryAgentToolResult>
/**
* Returns all currently available data for one source.
*
* Implementations should include whether the source is enabled, all feed
* items from `sourceId`, context entries owned by that source, available
* action summaries, and errors from that source. If `feedItemId` is provided,
* the result should also include the matching selected item from that source
* when present.
*/
getSourceData(sourceId: string, feedItemId?: string): Promise<QueryAgentToolResult>
/**
* Executes a source action and returns a serializable execution result.
*
* `sourceId` identifies the source, `actionId` identifies the action within
* that source, and `params` is the source-specific action payload. Tool
* wrappers validate the action envelope, while the source action schema owns
* payload validation. Implementations should let source/action validation
* errors propagate, and on success should return an `ok: true` result plus
* `details.actionExecution` for callers that need a structured record of
* what ran.
*/
executeAction(sourceId: string, actionId: string, params?: unknown): Promise<QueryAgentToolResult>
}

View File

@@ -1,36 +1,21 @@
export interface QueryAgentAsk {
userId: string
message: string
}
export interface ProposedAction {
id: string
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
requiresConfirmation: true
createdAt: string
}
export type QueryAgentEvent =
| { type: "text_delta"; text: string }
| { type: "tool_start"; toolName: string }
| { type: "tool_end"; toolName: string; ok: boolean }
| { type: "action_proposed"; action: ProposedAction }
| { type: "done" }
| { type: "error"; message: string }
export interface QueryAgent {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
disposeUser(userId: string): void
dispose(): void
}
export interface QueryAgentResponse {
message: string
proposedActions: ProposedAction[]
}
export class QueryAgentError extends Error {
@@ -45,16 +30,12 @@ export async function collectQueryAgentResponse(
input: QueryAgentAsk,
): Promise<QueryAgentResponse> {
let message = ""
const proposedActions: ProposedAction[] = []
for await (const event of agent.ask(input)) {
switch (event.type) {
case "text_delta":
message += event.text
break
case "action_proposed":
proposedActions.push(event.action)
break
case "error":
throw new QueryAgentError(event.message)
case "tool_start":
@@ -64,5 +45,5 @@ export async function collectQueryAgentResponse(
}
}
return { message, proposedActions }
return { message }
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, mock, test } from "bun:test"
import type { QueryAgentToolResult, QueryAgentToolbox } from "./query-agent-toolbox.ts"
mock.module("@earendil-works/pi-coding-agent", () => ({
defineTool(tool: unknown): unknown {
return tool
},
}))
interface TestTool {
name: string
parameters: unknown
execute(toolCallId: string, params: unknown): Promise<unknown>
}
describe("FREYA agent tools", () => {
test("rejects unknown top-level params", async () => {
const { createFreyaAgentTools, FREYA_GET_CONTEXT_TOOL } = await import("./tools.ts")
const tool = expectTool(
createFreyaAgentTools({ toolbox: createStubToolbox() }),
FREYA_GET_CONTEXT_TOOL,
)
await expect(
tool.execute("tool-call-1", {
key: ["freya.location"],
extra: true,
}),
).rejects.toThrow("extra")
})
test("rejects invalid context keys", async () => {
const { createFreyaAgentTools, FREYA_GET_CONTEXT_TOOL } = await import("./tools.ts")
const tool = expectTool(
createFreyaAgentTools({ toolbox: createStubToolbox() }),
FREYA_GET_CONTEXT_TOOL,
)
await expect(tool.execute("tool-call-1", { key: [] })).rejects.toThrow("key")
await expect(tool.execute("tool-call-1", { key: [["freya.location"]] })).rejects.toThrow("key")
await expect(
tool.execute("tool-call-1", { key: [{ nested: { invalid: true } }] }),
).rejects.toThrow("nested")
})
test("marks tool schemas as closed objects", async () => {
const { createFreyaAgentTools } = await import("./tools.ts")
const tools = createFreyaAgentTools({ toolbox: createStubToolbox() })
for (const tool of tools.map(expectTestTool)) {
expect(expectRecord(tool.parameters).additionalProperties).toBe(false)
}
})
})
function createStubToolbox(): QueryAgentToolbox {
return {
async listSources() {
return toolResult({ sources: [] })
},
async getContext(key, match) {
return toolResult({ key, match })
},
async getFeedItem(feedItemId) {
return toolResult({ feedItemId })
},
async queryContext(question, feedItemId) {
return toolResult({ question, feedItemId })
},
async listContext() {
return toolResult({ entries: [] })
},
async getSourceData(sourceId, feedItemId) {
return toolResult({ sourceId, feedItemId })
},
async executeAction(sourceId, actionId, params) {
return toolResult({ sourceId, actionId, params })
},
}
}
function toolResult(result: unknown): QueryAgentToolResult {
return {
content: [{ type: "text", text: JSON.stringify(result) }],
details: {},
}
}
function expectTool(tools: unknown[], name: string): TestTool {
const tool = tools.map(expectTestTool).find((candidate) => candidate.name === name)
if (!tool) {
throw new Error(`Missing test tool: ${name}`)
}
return tool
}
function expectTestTool(value: unknown): TestTool {
const record = expectRecord(value)
const execute = record.execute
if (typeof record.name !== "string" || typeof execute !== "function") {
throw new Error("Expected test tool")
}
return {
name: record.name,
parameters: record.parameters,
execute: execute as TestTool["execute"],
}
}
function expectRecord(value: unknown): Record<string, unknown> {
expect(typeof value).toBe("object")
expect(value).not.toBeNull()
expect(Array.isArray(value)).toBe(false)
return value as Record<string, unknown>
}

View File

@@ -1,17 +1,11 @@
import { defineTool } from "@earendil-works/pi-coding-agent"
import { type } from "arktype"
import { Type } from "typebox"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import type { ProposedAction } from "./query-agent.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
interface CreateFreyaAgentToolsConfig {
userId: string
sessionManager: UserSessionManager
clock: () => Date
proposeAction(action: ProposedAction): void
toolbox: QueryAgentToolbox
}
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context"
@@ -20,7 +14,42 @@ export const FREYA_GET_CONTEXT_TOOL = "freya_get_context"
export const FREYA_LIST_CONTEXT_TOOL = "freya_list_context"
export const FREYA_GET_SOURCE_DATA_TOOL = "freya_get_source_data"
export const FREYA_GET_FEED_ITEM_TOOL = "freya_get_feed_item"
export const FREYA_PROPOSE_ACTION_TOOL = "freya_propose_action"
export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action"
const ContextKeyObjectPart = type("Record<string, string | number | boolean>").narrow(
(value) => !Array.isArray(value),
)
const ContextKeyPart = type("string | number").or(ContextKeyObjectPart)
const GetContextToolParams = type({
"+": "reject",
key: ContextKeyPart.array().atLeastLength(1),
"match?": "'exact' | 'prefix'",
})
const GetFeedItemToolParams = type({
"+": "reject",
feedItemId: type.string.atLeastLength(1),
})
const QueryContextToolParams = type({
"+": "reject",
question: type.string.atLeastLength(1),
"feedItemId?": "string",
})
const GetSourceDataToolParams = type({
"+": "reject",
sourceId: type.string.atLeastLength(1),
"feedItemId?": "string",
})
const ExecuteActionToolParams = type({
"+": "reject",
sourceId: type.string.atLeastLength(1),
actionId: type.string.atLeastLength(1),
"params?": "unknown",
})
export const FREYA_AGENT_TOOL_NAMES = [
FREYA_LIST_SOURCES_TOOL,
@@ -29,20 +58,17 @@ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_QUERY_CONTEXT_TOOL,
FREYA_LIST_CONTEXT_TOOL,
FREYA_GET_SOURCE_DATA_TOOL,
FREYA_PROPOSE_ACTION_TOOL,
FREYA_EXECUTE_ACTION_TOOL,
]
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
const { userId } = config
const debugTools = createQueryDebugTools(config.sessionManager)
const listSourcesTool = defineTool({
name: FREYA_LIST_SOURCES_TOOL,
label: "List FREYA Sources",
description:
"List enabled FREYA source IDs and summarize available feed items, context entries, actions, and errors.",
parameters: Type.Object({}),
execute: async () => executeDebugTool(debugTools, userId, FREYA_LIST_SOURCES_TOOL, {}),
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeListSourcesTool(config.toolbox),
})
const getContextTool = defineTool({
@@ -50,7 +76,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Context",
description:
"Read specific FREYA context entries by key. Use prefix matching to discover entries under a source ID, or exact matching when you know the full key.",
parameters: Type.Object({
parameters: Type.Object(
{
key: Type.Array(Type.Unknown(), {
description:
'Context key array, for example ["freya.location"] or ["freya.location", "location"].',
@@ -60,20 +87,23 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
description: "Match mode. Defaults to prefix.",
}),
),
}),
execute: async (_toolCallId, params) =>
executeDebugTool(debugTools, userId, FREYA_GET_CONTEXT_TOOL, params),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetContextTool(config.toolbox, params),
})
const getFeedItemTool = defineTool({
name: FREYA_GET_FEED_ITEM_TOOL,
label: "Get FREYA Feed Item",
description: "Read one feed item by ID, including related source context, actions, and errors.",
parameters: Type.Object({
parameters: Type.Object(
{
feedItemId: Type.String({ description: "Feed item ID to inspect." }),
}),
execute: async (_toolCallId, params) =>
executeDebugTool(debugTools, userId, FREYA_GET_FEED_ITEM_TOOL, params),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetFeedItemTool(config.toolbox, params),
})
const queryContextTool = defineTool({
@@ -81,7 +111,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Query FREYA Context",
description:
"Read the user's current FREYA feed, source graph context, source errors, and available actions.",
parameters: Type.Object({
parameters: Type.Object(
{
question: Type.String({
description: "The specific personal-context question to answer.",
}),
@@ -90,8 +121,10 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
description: "Optional feed item ID when the user is asking about a specific card.",
}),
),
}),
execute: async (_toolCallId, params) => executeQueryContextTool(config, params),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeQueryContextTool(config.toolbox, params),
})
const listContextTool = defineTool({
@@ -99,8 +132,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "List FREYA Context",
description:
"List all current FREYA context graph entries for the user. Use this to inspect what personal context is available.",
parameters: Type.Object({}),
execute: async () => executeListContextTool(config),
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeListContextTool(config.toolbox),
})
const getSourceDataTool = defineTool({
@@ -108,7 +141,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Source Data",
description:
"Get current feed items, context entries, actions, and errors for a specific FREYA source ID.",
parameters: Type.Object({
parameters: Type.Object(
{
sourceId: Type.String({
description: "Source ID, for example freya.location, freya.tfl, or freya.weather.",
}),
@@ -117,32 +151,30 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
description: "Optional feed item ID to select one item from the source.",
}),
),
}),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config.toolbox, params),
})
const proposeActionTool = defineTool({
name: FREYA_PROPOSE_ACTION_TOOL,
label: "Propose FREYA Action",
description: "Create a proposed action for the user to review. This never executes the action.",
parameters: Type.Object({
title: Type.String({ description: "Short user-facing action title." }),
description: Type.String({
description: "What will happen if the user confirms this action.",
}),
sourceId: Type.Optional(
Type.String({ description: "Source ID that should execute the action, if known." }),
),
actionId: Type.Optional(
Type.String({ description: "Source action ID to execute after confirmation, if known." }),
),
const executeActionTool = defineTool({
name: FREYA_EXECUTE_ACTION_TOOL,
label: "Execute FREYA Action",
description:
"Execute an available FREYA source action immediately without creating a proposal.",
parameters: Type.Object(
{
sourceId: Type.String({ description: "Source ID that should execute the action." }),
actionId: Type.String({ description: "Source action ID to execute." }),
params: Type.Optional(
Type.Unknown({
description: "Parameters to pass to the source action after confirmation.",
description: "Parameters to pass to the source action.",
}),
),
}),
execute: async (_toolCallId, params) => executeProposeActionTool(config, params),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeActionToolCall(config.toolbox, params),
})
return [
@@ -152,173 +184,61 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
queryContextTool,
listContextTool,
getSourceDataTool,
proposeActionTool,
executeActionTool,
]
}
async function executeDebugTool(
debugTools: QueryDebugTools,
userId: string,
toolName: string,
params: unknown,
) {
const result = await debugTools.execute(userId, toolName, params)
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
details: {},
}
async function executeListSourcesTool(toolbox: QueryAgentToolbox) {
return toolbox.listSources()
}
async function executeQueryContextTool(
config: CreateFreyaAgentToolsConfig,
params: { question: string; feedItemId?: string },
) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const feedItemId = params.feedItemId
const selectedItem =
typeof feedItemId === "string" ? feed.items.find((item) => item.id === feedItemId) : undefined
const actions = await userSession.listActions()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
question: params.question,
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items: feed.items,
context: context.entries(),
availableActions: actions.map((entry) => ({
sourceId: entry.sourceId,
actions: Object.values(entry.actions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
})),
errors: feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
}),
},
],
details: {},
}
async function executeGetContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = GetContextToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
async function executeListContextTool(config: CreateFreyaAgentToolsConfig) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
await userSession.feed()
const context = userSession.engine.currentContext()
const entries = context.entries()
const match = params.match ?? "prefix"
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
count: entries.length,
entries,
}),
},
],
details: {},
}
return toolbox.getContext(params.key, match)
}
async function executeGetSourceDataTool(
config: CreateFreyaAgentToolsConfig,
params: { sourceId: string; feedItemId?: string },
) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const sourceActions = userSession.hasSource(params.sourceId)
? await userSession.engine.listActions(params.sourceId)
: {}
const items = feed.items.filter((item) => item.sourceId === params.sourceId)
const selectedItem =
params.feedItemId !== undefined
? items.find((item) => item.id === params.feedItemId)
: undefined
const contextEntries = context.entries().filter((entry) => entry.key[0] === params.sourceId)
const errors = feed.errors
.filter((error) => error.sourceId === params.sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
sourceId: params.sourceId,
hasSource: userSession.hasSource(params.sourceId),
feedItemId: params.feedItemId ?? null,
selectedItem: selectedItem ?? null,
items,
context: contextEntries,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
}),
},
],
details: {},
}
async function executeGetFeedItemTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = GetFeedItemToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
function executeProposeActionTool(
config: CreateFreyaAgentToolsConfig,
params: {
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
},
) {
const action: ProposedAction = {
id: crypto.randomUUID(),
title: params.title,
description: params.description,
requiresConfirmation: true,
createdAt: config.clock().toISOString(),
...(params.sourceId ? { sourceId: params.sourceId } : {}),
...(params.actionId ? { actionId: params.actionId } : {}),
...(params.params !== undefined ? { params: params.params } : {}),
return toolbox.getFeedItem(params.feedItemId)
}
config.proposeAction(action)
async function executeQueryContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = QueryContextToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
}),
},
],
details: { proposedAction: action },
return toolbox.queryContext(params.question, params.feedItemId)
}
async function executeListContextTool(toolbox: QueryAgentToolbox) {
return toolbox.listContext()
}
async function executeGetSourceDataTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = GetSourceDataToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
return toolbox.getSourceData(params.sourceId, params.feedItemId)
}
async function executeActionToolCall(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = ExecuteActionToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
return toolbox.executeAction(params.sourceId, params.actionId, params.params)
}

View File

@@ -0,0 +1,253 @@
import { contextKey, type ContextKeyPart } from "@freya/core"
import type { UserSession } from "../session/user-session.ts"
import type { QueryAgentToolResult, QueryAgentToolbox } from "./query-agent-toolbox.ts"
export class UserSessionQueryAgentToolbox implements QueryAgentToolbox {
constructor(private readonly session: UserSession) {}
async listSources(): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const contextEntries = context.entries()
const actions = await this.session.listActions()
const feedCounts = countBy(feed.items.map((item) => item.sourceId))
const contextCounts = countBy(
contextEntries
.map((entry) => entry.key[0])
.filter((part): part is string => typeof part === "string"),
)
const errors = groupErrorsBySource(
feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
)
const actionEntries = new Map(actions.map((entry) => [entry.sourceId, entry.actions]))
const sourceIds = new Set<string>([
...actionEntries.keys(),
...feedCounts.keys(),
...contextCounts.keys(),
...errors.keys(),
])
return toolResult({
time: context.time.toISOString(),
sources: [...sourceIds].sort().map((sourceId) => {
const sourceActions = actionEntries.get(sourceId) ?? {}
const feedItemCount = feedCounts.get(sourceId) ?? 0
const contextEntryCount = contextCounts.get(sourceId) ?? 0
return {
sourceId,
hasFeedItems: feedItemCount > 0,
feedItemCount,
hasContext: contextEntryCount > 0,
contextEntryCount,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors: errors.get(sourceId) ?? [],
}
}),
})
}
async getContext(
key: ContextKeyPart[],
match: "exact" | "prefix",
): Promise<QueryAgentToolResult> {
await this.session.feed()
const context = this.session.engine.currentContext()
const keyObject = contextKey(...key)
if (match === "exact") {
const value = context.get(keyObject)
return toolResult({
time: context.time.toISOString(),
match,
key,
found: value !== undefined,
value: value ?? null,
})
}
const entries = context.find(keyObject)
return toolResult({
time: context.time.toISOString(),
match,
key,
count: entries.length,
entries,
})
}
async getFeedItem(feedItemId: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const item = feed.items.find((candidate) => candidate.id === feedItemId)
if (!item) {
return toolResult({
time: context.time.toISOString(),
feedItemId,
found: false,
item: null,
})
}
const sourceActions = this.session.hasSource(item.sourceId)
? await this.session.engine.listActions(item.sourceId)
: {}
const errors = feed.errors
.filter((error) => error.sourceId === item.sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return toolResult({
time: context.time.toISOString(),
feedItemId,
found: true,
item,
source: {
sourceId: item.sourceId,
hasSource: this.session.hasSource(item.sourceId),
context: context.entries().filter((entry) => entry.key[0] === item.sourceId),
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
},
})
}
async queryContext(question: string, feedItemId?: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const selectedItem = feedItemId ? feed.items.find((item) => item.id === feedItemId) : undefined
const actions = await this.session.listActions()
return toolResult({
time: context.time.toISOString(),
question,
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items: feed.items,
context: context.entries(),
availableActions: actions.map((entry) => ({
sourceId: entry.sourceId,
actions: Object.values(entry.actions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
})),
errors: feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
})
}
async listContext(): Promise<QueryAgentToolResult> {
await this.session.feed()
const context = this.session.engine.currentContext()
const entries = context.entries()
return toolResult({
time: context.time.toISOString(),
count: entries.length,
entries,
})
}
async getSourceData(sourceId: string, feedItemId?: string): Promise<QueryAgentToolResult> {
const feed = await this.session.feed()
const context = this.session.engine.currentContext()
const sourceActions = this.session.hasSource(sourceId)
? await this.session.engine.listActions(sourceId)
: {}
const items = feed.items.filter((item) => item.sourceId === sourceId)
const selectedItem = feedItemId ? items.find((item) => item.id === feedItemId) : undefined
const contextEntries = context.entries().filter((entry) => entry.key[0] === sourceId)
const errors = feed.errors
.filter((error) => error.sourceId === sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return toolResult({
time: context.time.toISOString(),
sourceId,
hasSource: this.session.hasSource(sourceId),
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items,
context: contextEntries,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
})
}
async executeAction(
sourceId: string,
actionId: string,
params?: unknown,
): Promise<QueryAgentToolResult> {
const result = await this.session.engine.executeAction(sourceId, actionId, params)
const actionExecution = {
sourceId,
actionId,
result: result ?? null,
}
return toolResult(
{
ok: true,
...actionExecution,
},
{ actionExecution },
)
}
}
function toolResult(result: unknown, details: Record<string, unknown> = {}): QueryAgentToolResult {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result),
},
],
details,
}
}
function countBy(values: string[]): Map<string, number> {
const result = new Map<string, number>()
for (const value of values) {
result.set(value, (result.get(value) ?? 0) + 1)
}
return result
}
function groupErrorsBySource(
errors: Array<{ sourceId: string; message: string }>,
): Map<string, Array<{ sourceId: string; message: string }>> {
const result = new Map<string, Array<{ sourceId: string; message: string }>>()
for (const error of errors) {
const group = result.get(error.sourceId) ?? []
group.push(error)
result.set(error.sourceId, group)
}
return result
}

View File

@@ -11,7 +11,6 @@ describe("ensureEnv", () => {
EXA_API_KEY: " exa-key ",
GOOGLE_MAPS_API_KEY: " google-maps-key ",
OPENROUTER_API_KEY: " openrouter-key ",
OPENROUTER_MODEL: " model-name ",
TFL_API_KEY: " tfl-key ",
WEATHERKIT_KEY_ID: " weather-key-id ",
WEATHERKIT_PRIVATE_KEY: " weather-private-key ",
@@ -26,7 +25,6 @@ describe("ensureEnv", () => {
exaApiKey: "exa-key",
googleMapsApiKey: "google-maps-key",
openrouterApiKey: "openrouter-key",
openrouterModel: "model-name",
tflApiKey: "tfl-key",
weatherkitKeyId: "weather-key-id",
weatherkitPrivateKey: "weather-private-key",
@@ -53,25 +51,6 @@ describe("ensureEnv", () => {
).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY")
})
test("allows openrouter model to be omitted", () => {
const env = ensureEnv({
BETTER_AUTH_SECRET: "auth-secret",
CREDENTIAL_ENCRYPTION_KEY: "credential-key",
DATABASE_URL: "postgres://example",
EXA_API_KEY: "exa-key",
GOOGLE_MAPS_API_KEY: "google-maps-key",
OPENROUTER_API_KEY: "openrouter-key",
TFL_API_KEY: "tfl-key",
WEATHERKIT_KEY_ID: "weather-key-id",
WEATHERKIT_PRIVATE_KEY: "weather-private-key",
WEATHERKIT_SERVICE_ID: "weather-service-id",
WEATHERKIT_TEAM_ID: "weather-team-id",
})
expect(env.googleMapsApiKey).toBe("google-maps-key")
expect(env.openrouterModel).toBeUndefined()
})
test("throws with all missing required env names", () => {
expect(() => ensureEnv({})).toThrow(
"Missing required environment variables: BETTER_AUTH_SECRET, CREDENTIAL_ENCRYPTION_KEY, DATABASE_URL, EXA_API_KEY, OPENROUTER_API_KEY, TFL_API_KEY, WEATHERKIT_PRIVATE_KEY, WEATHERKIT_KEY_ID, WEATHERKIT_TEAM_ID, WEATHERKIT_SERVICE_ID, GOOGLE_MAPS_API_KEY",

View File

@@ -5,7 +5,6 @@ export interface ServerEnv {
exaApiKey: string
googleMapsApiKey: string
openrouterApiKey: string
openrouterModel: string | undefined
tflApiKey: string
weatherkitKeyId: string
weatherkitPrivateKey: string
@@ -39,7 +38,6 @@ export function ensureEnv(env: Record<string, string | undefined>): ServerEnv {
exaApiKey,
googleMapsApiKey,
openrouterApiKey,
openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"),
tflApiKey,
weatherkitKeyId,
weatherkitPrivateKey,

View File

@@ -4,7 +4,6 @@ import { cors } from "hono/cors"
import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createQueryDebugTools } from "./agent/debug-tools.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./agent/http.ts"
import { PiQueryAgent } from "./agent/pi-query-agent.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
@@ -35,11 +34,11 @@ function main() {
const feedEnhancer = createFeedEnhancer({
client: createLlmClient({
apiKey: env.openrouterApiKey,
model: env.openrouterModel,
}),
})
const credentialEncryptor = new CredentialEncryptor(env.credentialEncryptionKey)
const piApiKey = process.env.PI_API_KEY ?? env.openrouterApiKey
const sessionManager = new UserSessionManager({
db,
@@ -63,13 +62,9 @@ function main() {
],
feedEnhancer,
credentialEncryptor,
})
const piApiKey = process.env.PI_API_KEY ?? env.openrouterApiKey
const queryAgent = new PiQueryAgent({
sessionManager,
modelProvider: process.env.PI_MODEL_PROVIDER ?? "openrouter",
modelId: process.env.PI_MODEL ?? env.openrouterModel ?? "z-ai/glm-4.7-flash",
queryAgent: {
apiKey: piApiKey,
},
})
if (!piApiKey) {
console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable")
@@ -120,7 +115,7 @@ function main() {
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerAgentHttpHandlers(app, {
queryAgent,
sessionManager,
authSessionMiddleware,
})
if (isDebugMode) {
@@ -133,7 +128,7 @@ function main() {
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
process.on("SIGTERM", async () => {
queryAgent.dispose()
sessionManager.dispose()
await closeDb()
process.exit(0)
})

View File

@@ -1,7 +1,6 @@
import type { FeedSource } from "@freya/core"
import type { type } from "arktype"
export type ConfigSchema = ReturnType<typeof type>
export type ConfigSchema = (value: unknown) => unknown
export interface FeedSourceProvider {
/** The source ID this provider is responsible for (e.g., "freya.location"). */

View File

@@ -14,13 +14,14 @@ import {
SourceNotFoundError,
} from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
import { UserSession } from "./user-session.ts"
import { UserSession, type UserSessionAgentConfig } from "./user-session.ts"
export interface UserSessionManagerConfig {
db: Database
providers: FeedSourceProvider[]
feedEnhancer?: FeedEnhancer | null
credentialEncryptor?: CredentialEncryptor | null
queryAgent?: UserSessionAgentConfig
}
export class UserSessionManager {
@@ -30,6 +31,7 @@ export class UserSessionManager {
private readonly providers = new Map<string, FeedSourceProvider>()
private readonly feedEnhancer: FeedEnhancer | null
private readonly encryptor: CredentialEncryptor | null
private readonly queryAgentConfig: UserSessionAgentConfig | undefined
constructor(config: UserSessionManagerConfig) {
this.db = config.db
@@ -38,6 +40,7 @@ export class UserSessionManager {
}
this.feedEnhancer = config.feedEnhancer ?? null
this.encryptor = config.credentialEncryptor ?? null
this.queryAgentConfig = config.queryAgent
}
getProvider(sourceId: string): FeedSourceProvider | undefined {
@@ -99,6 +102,14 @@ export class UserSessionManager {
this.pending.delete(userId)
}
dispose(): void {
for (const session of this.sessions.values()) {
session.destroy()
}
this.sessions.clear()
this.pending.clear()
}
/**
* Merges, validates, and persists a user's source config and/or enabled
* state, then invalidates the cached session.
@@ -362,7 +373,7 @@ export class UserSessionManager {
}
if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer)
return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
}
const results = await Promise.allSettled(promises)
@@ -386,7 +397,7 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(userId, feedSources, this.feedEnhancer)
return new UserSession(userId, feedSources, this.feedEnhancer, this.queryAgentConfig)
}
/**

View File

@@ -58,6 +58,15 @@ describe("UserSession", () => {
expect(session.getSource("test")).toBeUndefined()
})
test("destroy disposes query agent", () => {
const session = new UserSession("test-user", [createStubSource("test")])
const disposeSpy = spyOn(session.agent, "dispose")
session.destroy()
expect(disposeSpy).toHaveBeenCalled()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession("test-user", [location])

View File

@@ -6,11 +6,24 @@ import {
type FeedSource,
} from "@freya/core"
import type { QueryAgentToolbox } from "../agent/query-agent-toolbox.ts"
import type { QueryAgent } from "../agent/query-agent.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import { PiQueryAgent } from "../agent/pi-query-agent.ts"
import { UserSessionQueryAgentToolbox } from "../agent/user-session-query-agent-toolbox.ts"
export interface UserSessionAgentConfig {
apiKey?: string
cwd?: string
systemPrompt?: string
}
export class UserSession {
readonly userId: string
readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null
@@ -19,7 +32,12 @@ export class UserSession {
private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
constructor(
userId: string,
sources: FeedSource[],
enhancer?: FeedEnhancer | null,
agentConfig?: UserSessionAgentConfig,
) {
this.userId = userId
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
@@ -35,6 +53,15 @@ export class UserSession {
})
}
this.toolbox = new UserSessionQueryAgentToolbox(this)
this.agent = new PiQueryAgent({
userId: this.userId,
toolbox: this.toolbox,
apiKey: agentConfig?.apiKey,
cwd: agentConfig?.cwd,
systemPrompt: agentConfig?.systemPrompt,
})
this.engine.start()
}
@@ -174,6 +201,7 @@ export class UserSession {
}
destroy(): void {
this.agent.dispose()
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()

View File

@@ -37,13 +37,13 @@ export function meta({}: Route.MetaArgs) {
},
{ property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
{ property: "og:url", content: "https://ael.is" },
{ property: "og:image", content: "https://freya.chat/social-media-preview.jpg" },
{ property: "og:url", content: "https://freya.chat" },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: PAGE_TITLE },
{ name: "twitter:description", content: PAGE_DESCRIPTION },
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
{ name: "twitter:image", content: "https://freya.chat/social-media-preview.jpg" },
]
}
@@ -84,7 +84,7 @@ export async function action({ request }: Route.ActionArgs) {
await new Promise((resolve) => setTimeout(resolve, 1000))
const emailRes = await resend.emails.send({
from: "Freya <no-reply@ael.is>",
from: "Freya <no-reply@freya.chat>",
to: email,
template: {
id: "waitlist-confirmation",
@@ -380,7 +380,6 @@ function SystemMessageBubble({
isAnimating={isStreaming}
linkSafety={{ enabled: false }}
components={{
// @ts-expect-error
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
}}
>

View File

@@ -40,7 +40,7 @@ const POLICY = `# Privacy Policy
**Last updated:** March 5, 2026
This Privacy Policy describes how **Freya** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
This Privacy Policy describes how **Freya** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://freya.chat** or interact with our services.
If you do not agree with this Privacy Policy, please do not use the website.

View File

@@ -1,4 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://ael.is/sitemap.xml
Sitemap: https://freya.chat/sitemap.xml

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://ael.is/</loc>
<loc>https://freya.chat/</loc>
</url>
<url>
<loc>https://ael.is/privacy</loc>
<loc>https://freya.chat/privacy</loc>
</url>
</urlset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB