Compare commits

..

1 Commits

Author SHA1 Message Date
333424fc0e fix: wrong social media preview aspect ratio 2026-06-15 14:19:33 +01:00
21 changed files with 350 additions and 785 deletions

View File

@@ -12,6 +12,8 @@ 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

@@ -1,7 +1,6 @@
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 { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
@@ -25,6 +24,8 @@ class FakeQueryAgent implements QueryAgent {
}
}
disposeUser(): void {}
dispose(): void {}
}
@@ -51,14 +52,8 @@ 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, {
sessionManager,
queryAgent,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app

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: {
sessionManager: UserSessionManager
queryAgent: QueryAgent
}
}
@@ -22,7 +22,7 @@ type DebugEnv = {
}
interface AgentHttpHandlersDeps {
sessionManager: UserSessionManager
queryAgent: QueryAgent
authSessionMiddleware: AuthSessionMiddleware
}
@@ -39,10 +39,10 @@ const AgentAskRequestBody = type({
export function registerAgentHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: AgentHttpHandlersDeps,
{ queryAgent, authSessionMiddleware }: AgentHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
c.set("queryAgent", queryAgent)
await next()
})
@@ -76,11 +76,11 @@ async function handleAgentAsk(c: Context<Env>) {
}
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
const queryAgent = c.get("queryAgent")
try {
const session = await sessionManager.getOrCreate(user.id)
const response = await collectQueryAgentResponse(session.agent, {
const response = await collectQueryAgentResponse(queryAgent, {
userId: user.id,
message: parsed.message,
})
return c.json(response)

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
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,4 +1,5 @@
export interface QueryAgentAsk {
userId: string
message: string
}
@@ -11,6 +12,7 @@ export type QueryAgentEvent =
export interface QueryAgent {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
disposeUser(userId: string): void
dispose(): void
}

View File

@@ -1,116 +0,0 @@
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,11 +1,14 @@
import { defineTool } from "@earendil-works/pi-coding-agent"
import { type } from "arktype"
import { Type } from "typebox"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
interface CreateFreyaAgentToolsConfig {
toolbox: QueryAgentToolbox
userId: string
sessionManager: UserSessionManager
}
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context"
@@ -16,41 +19,6 @@ 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_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,
FREYA_GET_CONTEXT_TOOL,
@@ -62,13 +30,16 @@ export const FREYA_AGENT_TOOL_NAMES = [
]
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({}, { additionalProperties: false }),
execute: async () => executeListSourcesTool(config.toolbox),
parameters: Type.Object({}),
execute: async () => executeDebugTool(debugTools, userId, FREYA_LIST_SOURCES_TOOL, {}),
})
const getContextTool = defineTool({
@@ -76,34 +47,30 @@ 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(
{
key: Type.Array(Type.Unknown(), {
description:
'Context key array, for example ["freya.location"] or ["freya.location", "location"].',
parameters: Type.Object({
key: Type.Array(Type.Unknown(), {
description:
'Context key array, for example ["freya.location"] or ["freya.location", "location"].',
}),
match: Type.Optional(
Type.Union([Type.Literal("exact"), Type.Literal("prefix")], {
description: "Match mode. Defaults to prefix.",
}),
match: Type.Optional(
Type.Union([Type.Literal("exact"), Type.Literal("prefix")], {
description: "Match mode. Defaults to prefix.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetContextTool(config.toolbox, params),
),
}),
execute: async (_toolCallId, params) =>
executeDebugTool(debugTools, userId, FREYA_GET_CONTEXT_TOOL, 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(
{
feedItemId: Type.String({ description: "Feed item ID to inspect." }),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetFeedItemTool(config.toolbox, params),
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),
})
const queryContextTool = defineTool({
@@ -111,20 +78,17 @@ 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(
{
question: Type.String({
description: "The specific personal-context question to answer.",
parameters: Type.Object({
question: Type.String({
description: "The specific personal-context question to answer.",
}),
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID when the user is asking about a specific card.",
}),
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID when the user is asking about a specific card.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeQueryContextTool(config.toolbox, params),
),
}),
execute: async (_toolCallId, params) => executeQueryContextTool(config, params),
})
const listContextTool = defineTool({
@@ -132,8 +96,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({}, { additionalProperties: false }),
execute: async () => executeListContextTool(config.toolbox),
parameters: Type.Object({}),
execute: async () => executeListContextTool(config),
})
const getSourceDataTool = defineTool({
@@ -141,20 +105,17 @@ 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(
{
sourceId: Type.String({
description: "Source ID, for example freya.location, freya.tfl, or freya.weather.",
parameters: Type.Object({
sourceId: Type.String({
description: "Source ID, for example freya.location, freya.tfl, or freya.weather.",
}),
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID to select one item from the source.",
}),
feedItemId: Type.Optional(
Type.String({
description: "Optional feed item ID to select one item from the source.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config.toolbox, params),
),
}),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
})
const executeActionTool = defineTool({
@@ -162,19 +123,16 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
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.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, params) => executeActionToolCall(config.toolbox, params),
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.",
}),
),
}),
execute: async (_toolCallId, params) => executeActionToolCall(config, params),
})
return [
@@ -188,57 +146,166 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
]
}
async function executeListSourcesTool(toolbox: QueryAgentToolbox) {
return toolbox.listSources()
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 executeGetContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = GetContextToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
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 executeListContextTool(config: CreateFreyaAgentToolsConfig) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
await userSession.feed()
const context = userSession.engine.currentContext()
const entries = context.entries()
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
time: context.time.toISOString(),
count: entries.length,
entries,
}),
},
],
details: {},
}
}
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 executeActionToolCall(
config: CreateFreyaAgentToolsConfig,
params: {
sourceId: string
actionId: string
params?: unknown
},
) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
const result = await userSession.engine.executeAction(
params.sourceId,
params.actionId,
params.params,
)
const actionExecution = {
sourceId: params.sourceId,
actionId: params.actionId,
result: result ?? null,
}
const match = params.match ?? "prefix"
return toolbox.getContext(params.key, match)
}
async function executeGetFeedItemTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = GetFeedItemToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
...actionExecution,
}),
},
],
details: { actionExecution },
}
return toolbox.getFeedItem(params.feedItemId)
}
async function executeQueryContextTool(toolbox: QueryAgentToolbox, rawParams: unknown) {
const params = QueryContextToolParams(rawParams)
if (params instanceof type.errors) {
throw new Error(params.summary)
}
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

@@ -1,253 +0,0 @@
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,6 +11,7 @@ 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 ",
@@ -25,6 +26,7 @@ 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",
@@ -51,6 +53,25 @@ 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,6 +5,7 @@ export interface ServerEnv {
exaApiKey: string
googleMapsApiKey: string
openrouterApiKey: string
openrouterModel: string | undefined
tflApiKey: string
weatherkitKeyId: string
weatherkitPrivateKey: string
@@ -38,6 +39,7 @@ export function ensureEnv(env: Record<string, string | undefined>): ServerEnv {
exaApiKey,
googleMapsApiKey,
openrouterApiKey,
openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"),
tflApiKey,
weatherkitKeyId,
weatherkitPrivateKey,

View File

@@ -4,6 +4,7 @@ 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"
@@ -34,11 +35,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,
@@ -62,9 +63,13 @@ function main() {
],
feedEnhancer,
credentialEncryptor,
queryAgent: {
apiKey: piApiKey,
},
})
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,
})
if (!piApiKey) {
console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable")
@@ -115,7 +120,7 @@ function main() {
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerAgentHttpHandlers(app, {
sessionManager,
queryAgent,
authSessionMiddleware,
})
if (isDebugMode) {
@@ -128,7 +133,7 @@ function main() {
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
process.on("SIGTERM", async () => {
sessionManager.dispose()
queryAgent.dispose()
await closeDb()
process.exit(0)
})

View File

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

View File

@@ -14,14 +14,13 @@ import {
SourceNotFoundError,
} from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
import { UserSession, type UserSessionAgentConfig } from "./user-session.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
db: Database
providers: FeedSourceProvider[]
feedEnhancer?: FeedEnhancer | null
credentialEncryptor?: CredentialEncryptor | null
queryAgent?: UserSessionAgentConfig
}
export class UserSessionManager {
@@ -31,7 +30,6 @@ 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
@@ -40,7 +38,6 @@ export class UserSessionManager {
}
this.feedEnhancer = config.feedEnhancer ?? null
this.encryptor = config.credentialEncryptor ?? null
this.queryAgentConfig = config.queryAgent
}
getProvider(sourceId: string): FeedSourceProvider | undefined {
@@ -102,14 +99,6 @@ 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.
@@ -373,7 +362,7 @@ export class UserSessionManager {
}
if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
return new UserSession(userId, [], this.feedEnhancer)
}
const results = await Promise.allSettled(promises)
@@ -397,7 +386,7 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(userId, feedSources, this.feedEnhancer, this.queryAgentConfig)
return new UserSession(userId, feedSources, this.feedEnhancer)
}
/**

View File

@@ -58,15 +58,6 @@ 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,24 +6,11 @@ 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
@@ -32,12 +19,7 @@ export class UserSession {
private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null
constructor(
userId: string,
sources: FeedSource[],
enhancer?: FeedEnhancer | null,
agentConfig?: UserSessionAgentConfig,
) {
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
this.userId = userId
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
@@ -53,15 +35,6 @@ 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()
}
@@ -201,7 +174,6 @@ 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://freya.chat/social-media-preview.jpg" },
{ property: "og:url", content: "https://freya.chat" },
{ property: "og:image", content: "https://ael.is/social-media-preview.jpg" },
{ property: "og:url", content: "https://ael.is" },
{ 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://freya.chat/social-media-preview.jpg" },
{ name: "twitter:image", content: "https://ael.is/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@freya.chat>",
from: "Freya <no-reply@ael.is>",
to: email,
template: {
id: "waitlist-confirmation",
@@ -380,6 +380,7 @@ 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://freya.chat** 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://ael.is** 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://freya.chat/sitemap.xml
Sitemap: https://ael.is/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://freya.chat/</loc>
<loc>https://ael.is/</loc>
</url>
<url>
<loc>https://freya.chat/privacy</loc>
<loc>https://ael.is/privacy</loc>
</url>
</urlset>