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 { interface QueryResponse {
message: string message: string
proposedActions: ProposedAction[]
} }
interface QueryToolDefinition { interface QueryToolDefinition {
@@ -187,7 +175,6 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string)
} }
console.log(`\nagent> ${data.message || "(no message)"}`) console.log(`\nagent> ${data.message || "(no message)"}`)
printProposedActions(data.proposedActions)
console.log("") console.log("")
} }
@@ -366,22 +353,6 @@ function printHelp(): void {
console.log(" /quit Exit\n") 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( function askRequired(
label: string, label: string,
defaultValue?: string, defaultValue?: string,
@@ -579,9 +550,7 @@ function isAuthSession(value: unknown): value is AuthSession {
function isQueryResponse(value: unknown): value is QueryResponse { function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false if (!isJsonObject(value)) return false
if (typeof value.message !== "string") return false return typeof value.message === "string"
if (!Array.isArray(value.proposedActions)) return false
return value.proposedActions.every(isProposedAction)
} }
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse { 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 { function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value) 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 (LLM feed enhancement)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials # Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY= 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() { function createTestDebugTools() {
@@ -109,6 +131,16 @@ function createTestDebugTools() {
async listActions(sourceId: string) { async listActions(sourceId: string) {
return actions[sourceId] ?? {} 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) { hasSource(sourceId: string) {
return sourceId in actions return sourceId in actions

View File

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

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.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 { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts" import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
@@ -24,8 +25,6 @@ class FakeQueryAgent implements QueryAgent {
} }
} }
disposeUser(): void {}
dispose(): void {} dispose(): void {}
} }
@@ -52,8 +51,14 @@ class FakeDebugTools implements QueryDebugTools {
function buildTestApp(queryAgent: QueryAgent, userId?: string) { function buildTestApp(queryAgent: QueryAgent, userId?: string) {
const app = new Hono() const app = new Hono()
const sessionManager = {
async getOrCreate() {
return { agent: queryAgent }
},
} as unknown as UserSessionManager
registerAgentHttpHandlers(app, { registerAgentHttpHandlers(app, {
queryAgent, sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId), authSessionMiddleware: mockAuthSessionMiddleware(userId),
}) })
return app return app
@@ -80,21 +85,10 @@ describe("POST /api/agent", () => {
expect(res.status).toBe(401) expect(res.status).toBe(401)
}) })
test("collects text deltas and proposed actions", async () => { test("collects text deltas", 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",
}
const agent = new FakeQueryAgent([ const agent = new FakeQueryAgent([
{ type: "text_delta", text: "You should " }, { type: "text_delta", text: "You should " },
{ type: "text_delta", text: "leave at 8:30." }, { type: "text_delta", text: "leave at 8:30." },
{ type: "action_proposed", action },
{ type: "done" }, { type: "done" },
]) ])
const app = buildTestApp(agent, "user-1") const app = buildTestApp(agent, "user-1")
@@ -112,10 +106,8 @@ describe("POST /api/agent", () => {
const body = (await res.json()) as { const body = (await res.json()) as {
message: string message: string
proposedActions: ProposedAction[]
} }
expect(body.message).toBe("You should leave at 8:30.") expect(body.message).toBe("You should leave at 8:30.")
expect(body.proposedActions).toEqual([action])
}) })
test("returns 400 for invalid body", async () => { test("returns 400 for invalid body", async () => {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
<identity> <identity>
You are Freya. You are a digital companion created by Kenneth. His twitter is @kennethnym. 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> </identity>
<action> <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_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> </action>
<behavior> <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 { export interface QueryAgentAsk {
userId: string
message: 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 = export type QueryAgentEvent =
| { type: "text_delta"; text: string } | { type: "text_delta"; text: string }
| { type: "tool_start"; toolName: string } | { type: "tool_start"; toolName: string }
| { type: "tool_end"; toolName: string; ok: boolean } | { type: "tool_end"; toolName: string; ok: boolean }
| { type: "action_proposed"; action: ProposedAction }
| { type: "done" } | { type: "done" }
| { type: "error"; message: string } | { type: "error"; message: string }
export interface QueryAgent { export interface QueryAgent {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
disposeUser(userId: string): void
dispose(): void dispose(): void
} }
export interface QueryAgentResponse { export interface QueryAgentResponse {
message: string message: string
proposedActions: ProposedAction[]
} }
export class QueryAgentError extends Error { export class QueryAgentError extends Error {
@@ -45,16 +30,12 @@ export async function collectQueryAgentResponse(
input: QueryAgentAsk, input: QueryAgentAsk,
): Promise<QueryAgentResponse> { ): Promise<QueryAgentResponse> {
let message = "" let message = ""
const proposedActions: ProposedAction[] = []
for await (const event of agent.ask(input)) { for await (const event of agent.ask(input)) {
switch (event.type) { switch (event.type) {
case "text_delta": case "text_delta":
message += event.text message += event.text
break break
case "action_proposed":
proposedActions.push(event.action)
break
case "error": case "error":
throw new QueryAgentError(event.message) throw new QueryAgentError(event.message)
case "tool_start": 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 { defineTool } from "@earendil-works/pi-coding-agent"
import { type } from "arktype"
import { Type } from "typebox" import { Type } from "typebox"
import type { UserSessionManager } from "../session/index.ts" import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import type { ProposedAction } from "./query-agent.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
interface CreateFreyaAgentToolsConfig { interface CreateFreyaAgentToolsConfig {
userId: string toolbox: QueryAgentToolbox
sessionManager: UserSessionManager
clock: () => Date
proposeAction(action: ProposedAction): void
} }
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context" 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_LIST_CONTEXT_TOOL = "freya_list_context"
export const FREYA_GET_SOURCE_DATA_TOOL = "freya_get_source_data" 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_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 = [ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_LIST_SOURCES_TOOL, FREYA_LIST_SOURCES_TOOL,
@@ -29,20 +58,17 @@ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_QUERY_CONTEXT_TOOL, FREYA_QUERY_CONTEXT_TOOL,
FREYA_LIST_CONTEXT_TOOL, FREYA_LIST_CONTEXT_TOOL,
FREYA_GET_SOURCE_DATA_TOOL, FREYA_GET_SOURCE_DATA_TOOL,
FREYA_PROPOSE_ACTION_TOOL, FREYA_EXECUTE_ACTION_TOOL,
] ]
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) { export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
const { userId } = config
const debugTools = createQueryDebugTools(config.sessionManager)
const listSourcesTool = defineTool({ const listSourcesTool = defineTool({
name: FREYA_LIST_SOURCES_TOOL, name: FREYA_LIST_SOURCES_TOOL,
label: "List FREYA Sources", label: "List FREYA Sources",
description: description:
"List enabled FREYA source IDs and summarize available feed items, context entries, actions, and errors.", "List enabled FREYA source IDs and summarize available feed items, context entries, actions, and errors.",
parameters: Type.Object({}), parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeDebugTool(debugTools, userId, FREYA_LIST_SOURCES_TOOL, {}), execute: async () => executeListSourcesTool(config.toolbox),
}) })
const getContextTool = defineTool({ const getContextTool = defineTool({
@@ -50,7 +76,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Context", label: "Get FREYA Context",
description: 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.", "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(), { key: Type.Array(Type.Unknown(), {
description: description:
'Context key array, for example ["freya.location"] or ["freya.location", "location"].', '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.", description: "Match mode. Defaults to prefix.",
}), }),
), ),
}), },
execute: async (_toolCallId, params) => { additionalProperties: false },
executeDebugTool(debugTools, userId, FREYA_GET_CONTEXT_TOOL, params), ),
execute: async (_toolCallId, params) => executeGetContextTool(config.toolbox, params),
}) })
const getFeedItemTool = defineTool({ const getFeedItemTool = defineTool({
name: FREYA_GET_FEED_ITEM_TOOL, name: FREYA_GET_FEED_ITEM_TOOL,
label: "Get FREYA Feed Item", label: "Get FREYA Feed Item",
description: "Read one feed item by ID, including related source context, actions, and errors.", 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." }), feedItemId: Type.String({ description: "Feed item ID to inspect." }),
}), },
execute: async (_toolCallId, params) => { additionalProperties: false },
executeDebugTool(debugTools, userId, FREYA_GET_FEED_ITEM_TOOL, params), ),
execute: async (_toolCallId, params) => executeGetFeedItemTool(config.toolbox, params),
}) })
const queryContextTool = defineTool({ const queryContextTool = defineTool({
@@ -81,7 +111,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Query FREYA Context", label: "Query FREYA Context",
description: description:
"Read the user's current FREYA feed, source graph context, source errors, and available actions.", "Read the user's current FREYA feed, source graph context, source errors, and available actions.",
parameters: Type.Object({ parameters: Type.Object(
{
question: Type.String({ question: Type.String({
description: "The specific personal-context question to answer.", 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.", 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({ const listContextTool = defineTool({
@@ -99,8 +132,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "List FREYA Context", label: "List FREYA Context",
description: description:
"List all current FREYA context graph entries for the user. Use this to inspect what personal context is available.", "List all current FREYA context graph entries for the user. Use this to inspect what personal context is available.",
parameters: Type.Object({}), parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => executeListContextTool(config), execute: async () => executeListContextTool(config.toolbox),
}) })
const getSourceDataTool = defineTool({ const getSourceDataTool = defineTool({
@@ -108,7 +141,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Get FREYA Source Data", label: "Get FREYA Source Data",
description: description:
"Get current feed items, context entries, actions, and errors for a specific FREYA source ID.", "Get current feed items, context entries, actions, and errors for a specific FREYA source ID.",
parameters: Type.Object({ parameters: Type.Object(
{
sourceId: Type.String({ sourceId: Type.String({
description: "Source ID, for example freya.location, freya.tfl, or freya.weather.", 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.", 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({ const executeActionTool = defineTool({
name: FREYA_PROPOSE_ACTION_TOOL, name: FREYA_EXECUTE_ACTION_TOOL,
label: "Propose FREYA Action", label: "Execute FREYA Action",
description: "Create a proposed action for the user to review. This never executes the action.", description:
parameters: Type.Object({ "Execute an available FREYA source action immediately without creating a proposal.",
title: Type.String({ description: "Short user-facing action title." }), parameters: Type.Object(
description: Type.String({ {
description: "What will happen if the user confirms this action.", sourceId: Type.String({ description: "Source ID that should execute the action." }),
}), actionId: Type.String({ description: "Source action ID to execute." }),
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." }),
),
params: Type.Optional( params: Type.Optional(
Type.Unknown({ 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 [ return [
@@ -152,173 +184,61 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
queryContextTool, queryContextTool,
listContextTool, listContextTool,
getSourceDataTool, getSourceDataTool,
proposeActionTool, executeActionTool,
] ]
} }
async function executeDebugTool( async function executeListSourcesTool(toolbox: QueryAgentToolbox) {
debugTools: QueryDebugTools, return toolbox.listSources()
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 executeQueryContextTool( async function executeGetContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
config: CreateFreyaAgentToolsConfig, const params = GetContextToolParams(rawParams)
params: { question: string; feedItemId?: string }, if (params instanceof type.errors) {
) { throw new Error(params.summary)
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: {},
} }
const match = params.match ?? "prefix"
return toolbox.getContext(params.key, match)
} }
async function executeListContextTool(config: CreateFreyaAgentToolsConfig) { async function executeGetFeedItemTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const userSession = await config.sessionManager.getOrCreate(config.userId) const params = GetFeedItemToolParams(rawParams)
await userSession.feed() if (params instanceof type.errors) {
const context = userSession.engine.currentContext() throw new Error(params.summary)
const entries = context.entries()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
count: entries.length,
entries,
}),
},
],
details: {},
} }
return toolbox.getFeedItem(params.feedItemId)
} }
async function executeGetSourceDataTool( async function executeQueryContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
config: CreateFreyaAgentToolsConfig, const params = QueryContextToolParams(rawParams)
params: { sourceId: string; feedItemId?: string }, if (params instanceof type.errors) {
) { throw new Error(params.summary)
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: {},
} }
return toolbox.queryContext(params.question, params.feedItemId)
} }
function executeProposeActionTool( async function executeListContextTool(toolbox: QueryAgentToolbox) {
config: CreateFreyaAgentToolsConfig, return toolbox.listContext()
params: { }
title: string
description: string async function executeGetSourceDataTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
sourceId?: string const params = GetSourceDataToolParams(rawParams)
actionId?: string if (params instanceof type.errors) {
params?: unknown throw new Error(params.summary)
}, }
) {
const action: ProposedAction = { return toolbox.getSourceData(params.sourceId, params.feedItemId)
id: crypto.randomUUID(), }
title: params.title,
description: params.description, async function executeActionToolCall(toolbox: QueryAgentToolbox, rawParams: unknown) {
requiresConfirmation: true, const params = ExecuteActionToolParams(rawParams)
createdAt: config.clock().toISOString(), if (params instanceof type.errors) {
...(params.sourceId ? { sourceId: params.sourceId } : {}), throw new Error(params.summary)
...(params.actionId ? { actionId: params.actionId } : {}), }
...(params.params !== undefined ? { params: params.params } : {}),
} return toolbox.executeAction(params.sourceId, params.actionId, params.params)
config.proposeAction(action)
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
}),
},
],
details: { proposedAction: action },
}
} }

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 ", EXA_API_KEY: " exa-key ",
GOOGLE_MAPS_API_KEY: " google-maps-key ", GOOGLE_MAPS_API_KEY: " google-maps-key ",
OPENROUTER_API_KEY: " openrouter-key ", OPENROUTER_API_KEY: " openrouter-key ",
OPENROUTER_MODEL: " model-name ",
TFL_API_KEY: " tfl-key ", TFL_API_KEY: " tfl-key ",
WEATHERKIT_KEY_ID: " weather-key-id ", WEATHERKIT_KEY_ID: " weather-key-id ",
WEATHERKIT_PRIVATE_KEY: " weather-private-key ", WEATHERKIT_PRIVATE_KEY: " weather-private-key ",
@@ -26,7 +25,6 @@ describe("ensureEnv", () => {
exaApiKey: "exa-key", exaApiKey: "exa-key",
googleMapsApiKey: "google-maps-key", googleMapsApiKey: "google-maps-key",
openrouterApiKey: "openrouter-key", openrouterApiKey: "openrouter-key",
openrouterModel: "model-name",
tflApiKey: "tfl-key", tflApiKey: "tfl-key",
weatherkitKeyId: "weather-key-id", weatherkitKeyId: "weather-key-id",
weatherkitPrivateKey: "weather-private-key", weatherkitPrivateKey: "weather-private-key",
@@ -53,25 +51,6 @@ describe("ensureEnv", () => {
).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY") ).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", () => { test("throws with all missing required env names", () => {
expect(() => ensureEnv({})).toThrow( 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", "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 exaApiKey: string
googleMapsApiKey: string googleMapsApiKey: string
openrouterApiKey: string openrouterApiKey: string
openrouterModel: string | undefined
tflApiKey: string tflApiKey: string
weatherkitKeyId: string weatherkitKeyId: string
weatherkitPrivateKey: string weatherkitPrivateKey: string
@@ -39,7 +38,6 @@ export function ensureEnv(env: Record<string, string | undefined>): ServerEnv {
exaApiKey, exaApiKey,
googleMapsApiKey, googleMapsApiKey,
openrouterApiKey, openrouterApiKey,
openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"),
tflApiKey, tflApiKey,
weatherkitKeyId, weatherkitKeyId,
weatherkitPrivateKey, weatherkitPrivateKey,

View File

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

View File

@@ -1,7 +1,6 @@
import type { FeedSource } from "@freya/core" 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 { export interface FeedSourceProvider {
/** The source ID this provider is responsible for (e.g., "freya.location"). */ /** The source ID this provider is responsible for (e.g., "freya.location"). */

View File

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

View File

@@ -6,11 +6,24 @@ import {
type FeedSource, type FeedSource,
} from "@freya/core" } 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 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 { export class UserSession {
readonly userId: string readonly userId: string
readonly engine: FeedEngine readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null private enhancedItems: FeedItem[] | null = null
@@ -19,7 +32,12 @@ export class UserSession {
private enhancingPromise: Promise<void> | null = null private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => 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.userId = userId
this.engine = new FeedEngine() this.engine = new FeedEngine()
this.enhancer = enhancer ?? null 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() this.engine.start()
} }
@@ -174,6 +201,7 @@ export class UserSession {
} }
destroy(): void { destroy(): void {
this.agent.dispose()
this.unsubscribe?.() this.unsubscribe?.()
this.unsubscribe = null this.unsubscribe = null
this.engine.stop() this.engine.stop()

View File

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

View File

@@ -40,7 +40,7 @@ const POLICY = `# Privacy Policy
**Last updated:** March 5, 2026 **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. If you do not agree with this Privacy Policy, please do not use the website.

View File

@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Allow: / 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"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://ael.is/</loc> <loc>https://freya.chat/</loc>
</url> </url>
<url> <url>
<loc>https://ael.is/privacy</loc> <loc>https://freya.chat/privacy</loc>
</url> </url>
</urlset> </urlset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB