Compare commits

..

1 Commits

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

View File

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

@@ -1,49 +1 @@
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");--> statement-breakpoint CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
CREATE TABLE "conversation_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"sequence" integer NOT NULL,
"kind" text NOT NULL,
"visibility" text DEFAULT 'internal' NOT NULL,
"file_id" uuid,
"payload" jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "conversation_entries_conversation_id_sequence_unique" UNIQUE("conversation_id","sequence")
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"storage_key" text NOT NULL,
"original_name" text,
"mime_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key")
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_attachment_file_id_check" CHECK (("conversation_entries"."kind" = 'attachment' and "conversation_entries"."file_id" is not null) or ("conversation_entries"."kind" <> 'attachment' and "conversation_entries"."file_id" is null));--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "files" ADD CONSTRAINT "files_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "conversation_entries_conversation_id_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","sequence");--> statement-breakpoint
CREATE INDEX "conversation_entries_conversation_id_visibility_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","visibility","sequence");--> statement-breakpoint
CREATE INDEX "conversation_entries_kind_idx" ON "conversation_entries" USING btree ("kind");--> statement-breakpoint
CREATE INDEX "conversation_entries_file_id_idx" ON "conversation_entries" USING btree ("file_id");--> statement-breakpoint
CREATE INDEX "conversations_user_id_updated_at_idx" ON "conversations" USING btree ("user_id","updated_at");--> statement-breakpoint
CREATE INDEX "files_user_id_created_at_idx" ON "files" USING btree ("user_id","created_at");

File diff suppressed because it is too large Load Diff

View File

@@ -44,20 +44,6 @@ mock.module("../sources/user-sources.ts", () => ({
}), }),
})) }))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation() {
return { id: `conversation-${userId}` }
},
async listEntries() {
return []
},
async appendEntry() {
return { id: "entry-1", sequence: 1 }
},
}),
}))
function createStubSource(id: string): FeedSource { function createStubSource(id: string): FeedSource {
return { return {
id, id,

View File

@@ -1,347 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type {
ConversationStorage,
ConversationStorageEntry,
} from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { ConversationRecordingQueryAgent } from "./conversation-recording-query-agent.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactionEvent,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
interface RecordedEntry {
conversationId: string
input: AppendConversationEntryInput
}
class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentStreamEvent[]
private readonly eventListeners = createQueryAgentEventListeners()
constructor(events: QueryAgentStreamEvent[]) {
this.events = events
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
this.inputs.push(input)
for (const event of this.events) {
yield event
}
}
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
async emitCompaction(event: QueryAgentCompactionEvent): Promise<void> {
await this.emitEvent(event)
}
private async emitEvent<T extends QueryAgentEvent>(event: QueryAgentEventMap[T]): Promise<void> {
const listeners = this.listenersFor(event.type)
for (const listener of listeners) {
await listener(event)
}
}
private listenersFor<T extends QueryAgentEvent>(type: T): QueryAgentEventListeners[T] {
return this.eventListeners[type]
}
dispose(): void {}
}
class FakeConversationStorage implements ConversationStorage {
getOrCreateCount = 0
readonly entries: RecordedEntry[] = []
conversationId = "conversation-1"
async getOrCreateConversation(): Promise<{ id: string }> {
this.getOrCreateCount += 1
return { id: this.conversationId }
}
async appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
this.entries.push({ conversationId, input })
return {
id: `entry-${this.entries.length}`,
sequence: this.entries.length,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
}
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
return this.entries.map((entry, index) => ({
id: `entry-${index + 1}`,
sequence: index + 1,
kind: entry.input.kind,
payload: entry.input.payload,
metadata: entry.input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}))
}
}
describe("ConversationRecordingQueryAgent", () => {
test("records user and assistant messages in the conversation timeline", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "text_delta", text: "Hello " },
{ type: "text_delta", text: "there." },
{ type: "done" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
message: "hi",
}),
)
expect(events[0]).toEqual({ type: "conversation", conversationId: "conversation-1" })
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-1")
expect(storage.getOrCreateCount).toBe(1)
expect(storage.entries).toHaveLength(2)
const userEntry = storage.entries[0]!.input
if (userEntry.kind !== ConversationEntryKind.UserMessage) {
throw new Error("Expected user message entry")
}
expect(userEntry.payload.parts).toEqual([{ type: "text", text: "hi" }])
const assistantEntry = storage.entries[1]!.input
if (assistantEntry.kind !== ConversationEntryKind.AssistantMessage) {
throw new Error("Expected assistant message entry")
}
expect(assistantEntry.payload.parts).toEqual([{ type: "text", text: "Hello there." }])
expect(assistantEntry.metadata?.modelRun?.provider).toBe("openrouter")
expect(assistantEntry.metadata?.modelRun?.model).toBe("test-model")
})
test("uses a provided conversation id without creating a default conversation", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
conversationId: "conversation-existing",
message: "continue",
}),
)
expect(events[0]).toEqual({
type: "conversation",
conversationId: "conversation-existing",
})
expect(storage.getOrCreateCount).toBe(0)
expect(storage.entries[0]?.conversationId).toBe("conversation-existing")
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-existing")
})
test("uses the eager default conversation id without reading storage on ask", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-eager",
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
message: "continue",
}),
)
expect(events[0]).toEqual({
type: "conversation",
conversationId: "conversation-eager",
})
expect(storage.getOrCreateCount).toBe(0)
expect(storage.entries[0]?.conversationId).toBe("conversation-eager")
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-eager")
})
test("rejects switching away from the eager default conversation", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-eager",
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
conversationId: "conversation-other",
message: "continue",
}),
)
expect(events).toEqual([
{
type: "error",
message: "Conversation switching is not supported for this session",
},
])
expect(storage.entries).toHaveLength(0)
expect(queryAgent.inputs).toHaveLength(0)
})
test("records tool activity and agent errors as internal entries", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "tool_start", toolName: "freya_get_feed" },
{ type: "tool_end", toolName: "freya_get_feed", ok: true },
{ type: "error", message: "model unavailable" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
await collectEvents(
agent.ask({
message: "what now?",
}),
)
expect(storage.entries.map((entry) => entry.input.kind)).toEqual([
ConversationEntryKind.UserMessage,
ConversationEntryKind.ToolCall,
ConversationEntryKind.ToolResult,
ConversationEntryKind.SystemNote,
])
const toolCall = storage.entries[1]!.input
if (toolCall.kind !== ConversationEntryKind.ToolCall) {
throw new Error("Expected tool call entry")
}
expect(toolCall.payload.toolName).toBe("freya_get_feed")
const toolResult = storage.entries[2]!.input
if (toolResult.kind !== ConversationEntryKind.ToolResult) {
throw new Error("Expected tool result entry")
}
expect(toolResult.payload.ok).toBe(true)
const systemNote = storage.entries[3]!.input
if (systemNote.kind !== ConversationEntryKind.SystemNote) {
throw new Error("Expected system note entry")
}
expect(systemNote.payload).toMatchObject({
type: "agent_error",
message: "model unavailable",
})
})
test("records compaction events as context summaries", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "text_delta", text: "Kept answer." },
{ type: "done" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-1",
modelProvider: "openrouter",
modelId: "test-model",
})
const forwardedCompactions: QueryAgentCompactionEvent[] = []
agent.addEventListener(QueryAgentEvent.Compaction, (event) => {
forwardedCompactions.push(event)
})
await collectEvents(
agent.ask({
message: "remember this",
}),
)
await queryAgent.emitCompaction({
type: QueryAgentEvent.Compaction,
conversationId: "conversation-1",
summary: "The user prefers compact summaries.",
firstKeptEntryId: "pi-entry-7",
compactedEntryRange: {
startSequence: 1,
endSequence: 1,
},
tokensBefore: 1234,
details: { reason: "threshold" },
fromExtension: false,
})
const summaryEntry = storage.entries.at(-1)?.input
if (summaryEntry?.kind !== ConversationEntryKind.ContextSummary) {
throw new Error("Expected context summary entry")
}
expect(summaryEntry.payload.covers).toEqual({
startSequence: 1,
endSequence: 1,
})
expect(summaryEntry.payload.summary.importantDetails).toEqual([
"The user prefers compact summaries.",
])
expect(summaryEntry.metadata?.piCompaction).toMatchObject({
firstKeptEntryId: "pi-entry-7",
tokensBefore: 1234,
fromExtension: false,
details: { reason: "threshold" },
})
expect(forwardedCompactions).toHaveLength(1)
})
})
async function collectEvents(
events: AsyncIterable<QueryAgentStreamEvent>,
): Promise<QueryAgentStreamEvent[]> {
const result: QueryAgentStreamEvent[] = []
for await (const event of events) {
result.push(event)
}
return result
}

View File

@@ -1,252 +0,0 @@
import { randomUUID } from "node:crypto"
import type {
AppendConversationEntryInput,
ConversationEntryRow,
} from "../conversations/storage.ts"
import type { ConversationEntryMetadata } from "../conversations/types.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactionEvent,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
export interface ConversationStorage {
getOrCreateConversation(): Promise<{ id: string }>
appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry>
listEntries(conversationId: string): Promise<ConversationStorageEntry[]>
}
export type ConversationStorageEntry = Pick<
ConversationEntryRow,
"id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt"
>
export interface ConversationRecordingQueryAgentConfig {
agent: QueryAgent
storage: ConversationStorage
defaultConversationId?: string
route?: string
modelProvider: string
modelId: string
}
const DefaultRoute = "agent_query"
export class ConversationRecordingQueryAgent implements QueryAgent {
private readonly agent: QueryAgent
private readonly storage: ConversationStorage
private readonly defaultConversationId: string | undefined
private readonly route: string
private readonly modelProvider: string
private readonly modelId: string
private readonly eventListeners = createQueryAgentEventListeners()
private readonly removeAgentCompactionListener: () => void
constructor(config: ConversationRecordingQueryAgentConfig) {
this.agent = config.agent
this.storage = config.storage
this.defaultConversationId = config.defaultConversationId
this.route = config.route ?? DefaultRoute
this.modelProvider = config.modelProvider
this.modelId = config.modelId
this.removeAgentCompactionListener = this.agent.addEventListener(
QueryAgentEvent.Compaction,
async (event) => {
await this.appendCompactionSummary(event)
await this.emitEvent(event)
},
)
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
if (
this.defaultConversationId &&
input.conversationId &&
input.conversationId !== this.defaultConversationId
) {
yield {
type: "error",
message: "Conversation switching is not supported for this session",
}
return
}
const conversationId =
input.conversationId ??
this.defaultConversationId ??
(await this.storage.getOrCreateConversation()).id
const runId = randomUUID()
const userEntry = await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: input.message }],
},
metadata: { runId },
})
yield { type: "conversation", conversationId }
const assistantText: string[] = []
for await (const event of this.agent.ask({
...input,
conversationId,
userMessageEntry: {
id: userEntry.id,
sequence: userEntry.sequence,
},
})) {
switch (event.type) {
case "conversation":
break
case "text_delta":
assistantText.push(event.text)
yield event
break
case "tool_start":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.ToolCall,
payload: {
toolName: event.toolName,
runId,
},
metadata: { runId },
})
yield event
break
case "tool_end":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.ToolResult,
payload: {
toolName: event.toolName,
ok: event.ok,
runId,
},
metadata: { runId },
})
yield event
break
case "error":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.SystemNote,
payload: {
type: "agent_error",
message: event.message,
runId,
},
metadata: { runId },
})
yield event
return
case "done":
await this.appendAssistantMessage(conversationId, assistantText, runId)
yield event
return
}
}
await this.appendAssistantMessage(conversationId, assistantText, runId)
}
dispose(): void {
this.removeAgentCompactionListener()
this.clearEventListeners()
this.agent.dispose()
}
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
private async appendAssistantMessage(
conversationId: string,
assistantText: string[],
runId: string,
): Promise<void> {
const text = assistantText.join("")
if (text.length === 0) return
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text }],
},
metadata: this.modelRunMetadata(runId),
})
}
private modelRunMetadata(runId: string): ConversationEntryMetadata {
const metadata: ConversationEntryMetadata = { runId }
metadata.modelRun = {
route: this.route,
provider: this.modelProvider,
model: this.modelId,
}
return metadata
}
private async appendCompactionSummary(event: QueryAgentCompactionEvent): Promise<void> {
if (event.compactedEntryRange === null) return
await this.storage.appendEntry(event.conversationId, {
kind: ConversationEntryKind.ContextSummary,
payload: {
covers: event.compactedEntryRange,
summary: {
durableFacts: [],
preferences: [],
decisions: [],
openTasks: [],
importantDetails: [event.summary],
},
promptVersion: "pi-sdk-compaction-v1",
},
metadata: {
piCompaction: {
firstKeptEntryId: event.firstKeptEntryId,
tokensBefore: event.tokensBefore,
fromExtension: event.fromExtension,
details: event.details,
},
},
})
}
private async emitEvent<T extends QueryAgentEvent>(event: QueryAgentEventMap[T]): Promise<void> {
const listeners = this.listenersFor(event.type)
for (const listener of listeners) {
await listener(event)
}
}
private listenersFor<T extends QueryAgentEvent>(type: T): QueryAgentEventListeners[T] {
return this.eventListeners[type]
}
private clearEventListeners(): void {
for (const listeners of Object.values(this.eventListeners)) {
listeners.clear()
}
}
}

View File

@@ -1,15 +1,8 @@
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 { import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
QueryAgent,
QueryAgentAsk,
QueryAgentEventListener,
QueryAgentStreamEvent,
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"
@@ -18,25 +11,20 @@ const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
class FakeQueryAgent implements QueryAgent { class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = [] readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentStreamEvent[] private readonly events: QueryAgentEvent[]
constructor(events: QueryAgentStreamEvent[]) { constructor(events: QueryAgentEvent[]) {
this.events = events this.events = events
} }
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> { async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
this.inputs.push(input) this.inputs.push(input)
for (const event of this.events) { for (const event of this.events) {
yield event yield event
} }
} }
addEventListener<T extends QueryAgentEvent>( disposeUser(): void {}
_type: T,
_listener: QueryAgentEventListener<T>,
): () => void {
return () => {}
}
dispose(): void {} dispose(): void {}
} }
@@ -64,14 +52,8 @@ 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, {
sessionManager, queryAgent,
authSessionMiddleware: mockAuthSessionMiddleware(userId), authSessionMiddleware: mockAuthSessionMiddleware(userId),
}) })
return app return app
@@ -123,27 +105,6 @@ describe("POST /api/agent", () => {
expect(body.message).toBe("You should leave at 8:30.") expect(body.message).toBe("You should leave at 8:30.")
}) })
test("passes conversation id to the query agent", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "done" },
])
const app = buildTestApp(agent, "user-1")
const res = await app.request("/api/agent", {
method: "POST",
body: JSON.stringify({
message: "Continue this chat.",
conversationId: "conversation-1",
}),
})
expect(res.status).toBe(200)
expect(agent.inputs[0]?.conversationId).toBe("conversation-1")
const body = (await res.json()) as { conversationId?: string }
expect(body.conversationId).toBe("conversation-1")
})
test("returns 400 for invalid body", async () => { test("returns 400 for invalid body", async () => {
const app = buildTestApp(new FakeQueryAgent([]), "user-1") const app = buildTestApp(new FakeQueryAgent([]), "user-1")

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: {
sessionManager: UserSessionManager queryAgent: QueryAgent
} }
} }
@@ -22,7 +22,7 @@ type DebugEnv = {
} }
interface AgentHttpHandlersDeps { interface AgentHttpHandlersDeps {
sessionManager: UserSessionManager queryAgent: QueryAgent
authSessionMiddleware: AuthSessionMiddleware authSessionMiddleware: AuthSessionMiddleware
} }
@@ -35,15 +35,14 @@ interface AgentDebugHttpHandlersDeps {
const AgentAskRequestBody = type({ const AgentAskRequestBody = type({
"+": "reject", "+": "reject",
message: "string", message: "string",
"conversationId?": "string",
}) })
export function registerAgentHttpHandlers( export function registerAgentHttpHandlers(
app: Hono, app: Hono,
{ sessionManager, authSessionMiddleware }: AgentHttpHandlersDeps, { queryAgent, authSessionMiddleware }: AgentHttpHandlersDeps,
) { ) {
const inject = createMiddleware<Env>(async (c, next) => { const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager) c.set("queryAgent", queryAgent)
await next() await next()
}) })
@@ -77,13 +76,12 @@ async function handleAgentAsk(c: Context<Env>) {
} }
const user = c.get("user")! const user = c.get("user")!
const sessionManager = c.get("sessionManager") const queryAgent = c.get("queryAgent")
try { try {
const session = await sessionManager.getOrCreate(user.id) const response = await collectQueryAgentResponse(queryAgent, {
const response = await collectQueryAgentResponse(session.agent, { userId: user.id,
message: parsed.message, message: parsed.message,
conversationId: parsed.conversationId,
}) })
return c.json(response) return c.json(response)
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,43 @@
import { createExtensionRuntime, type ResourceLoader } from "@earendil-works/pi-coding-agent"
export class InMemoryResourceLoader implements ResourceLoader {
private readonly extensions: ReturnType<ResourceLoader["getExtensions"]> = {
extensions: [],
errors: [],
runtime: createExtensionRuntime(),
}
constructor(private readonly systemPrompt: string) {}
getExtensions(): ReturnType<ResourceLoader["getExtensions"]> {
return this.extensions
}
getSkills(): ReturnType<ResourceLoader["getSkills"]> {
return { skills: [], diagnostics: [] }
}
getPrompts(): ReturnType<ResourceLoader["getPrompts"]> {
return { prompts: [], diagnostics: [] }
}
getThemes(): ReturnType<ResourceLoader["getThemes"]> {
return { themes: [], diagnostics: [] }
}
getAgentsFiles(): ReturnType<ResourceLoader["getAgentsFiles"]> {
return { agentsFiles: [] }
}
getSystemPrompt(): string {
return this.systemPrompt
}
getAppendSystemPrompt(): string[] {
return []
}
extendResources(_paths: Parameters<ResourceLoader["extendResources"]>[0]): void {}
async reload(_options?: Parameters<ResourceLoader["reload"]>[0]): Promise<void> {}
}

View File

@@ -1,10 +1,7 @@
import { beforeEach, describe, expect, mock, test } from "bun:test" 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 { QueryAgentStreamEvent } from "./query-agent.ts" import type { QueryAgentEvent } from "./query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession { interface FakePiSession {
subscribe(listener: (event: unknown) => void): () => void subscribe(listener: (event: unknown) => void): () => void
@@ -12,65 +9,8 @@ interface FakePiSession {
dispose(): void dispose(): void
} }
type CapturedExtensionHandler = (event: unknown) => Promise<unknown> | unknown
interface CapturedExtensionApi {
on(event: string, handler: CapturedExtensionHandler): void
}
type CapturedExtensionFactory = (pi: CapturedExtensionApi) => Promise<void> | void
interface CapturedExtension {
handlers: Map<string, CapturedExtensionHandler[]>
}
interface CapturedResourceLoader {
getExtensions(): unknown
}
interface CapturedDefaultResourceLoaderOptions {
extensionFactories?: CapturedExtensionFactory[]
}
class FakeDefaultResourceLoader implements CapturedResourceLoader {
private readonly extensionFactories: CapturedExtensionFactory[]
private extensionsResult: { extensions: CapturedExtension[] }
constructor(options: unknown) {
this.extensionFactories = isDefaultResourceLoaderOptions(options)
? (options.extensionFactories ?? [])
: []
this.extensionsResult = { extensions: [] }
}
async reload(): Promise<void> {
const handlers: CapturedExtension["handlers"] = new Map()
const api: CapturedExtensionApi = {
on(event: string, handler: CapturedExtensionHandler): void {
const existing = handlers.get(event) ?? []
existing.push(handler)
handlers.set(event, existing)
},
}
for (const factory of this.extensionFactories) {
await factory(api)
}
this.extensionsResult = {
extensions: [{ handlers }],
}
}
getExtensions(): unknown {
return this.extensionsResult
}
}
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> = []
@@ -109,51 +49,11 @@ const fakeSession: FakePiSession = {
dispose(): void {}, dispose(): void {},
} }
class FakeSessionManager {
private messages: unknown[] = []
private compaction: { summary: string; tokensBefore: number; timestamp: number } | null = null
appendMessage(message: unknown): string {
this.messages.push(message)
return `message-${this.messages.length}`
}
appendCompaction(summary: string, _firstKeptEntryId: string, tokensBefore: number): string {
this.compaction = {
summary,
tokensBefore,
timestamp: Date.now(),
}
this.messages = []
return "compaction-1"
}
buildSessionContext(): unknown {
const messages = [...this.messages]
if (this.compaction) {
messages.unshift({
role: "compactionSummary",
summary: this.compaction.summary,
tokensBefore: this.compaction.tokensBefore,
timestamp: this.compaction.timestamp,
})
}
return {
messages,
thinkingLevel: "off",
model: modelFromMessages(messages),
}
}
}
mock.module("@earendil-works/pi-coding-agent", () => ({ 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 })
},
} }
}, },
}, },
@@ -167,15 +67,13 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
createExtensionRuntime() { createExtensionRuntime() {
return {} return {}
}, },
DefaultResourceLoader: FakeDefaultResourceLoader,
defineTool(tool: unknown): unknown { defineTool(tool: unknown): unknown {
return tool return tool
}, },
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" }
}, },
} }
@@ -183,7 +81,7 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
}, },
SessionManager: { SessionManager: {
inMemory(_cwd: string): unknown { inMemory(_cwd: string): unknown {
return new FakeSessionManager() return {}
}, },
}, },
SettingsManager: { SettingsManager: {
@@ -196,8 +94,6 @@ 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 = []
@@ -228,14 +124,16 @@ 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({
toolbox: createStubToolbox(), sessionManager: createStubSessionManager(),
apiKey: "test-api-key", modelProvider: "mock",
modelId: "mock-model",
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",
}), }),
) )
@@ -244,6 +142,7 @@ describe("PiQueryAgent", () => {
const secondEvents = await collectEvents( const secondEvents = await collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "second", message: "second",
}), }),
) )
@@ -251,12 +150,10 @@ describe("PiQueryAgent", () => {
expect(secondEvents).toEqual([ expect(secondEvents).toEqual([
{ {
type: "error", type: "error",
message: "A query is already running", message: "A query is already running for this user",
}, },
]) ])
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()
@@ -271,228 +168,6 @@ describe("PiQueryAgent", () => {
} }
expect("agentDir" in createAgentSessionOptions).toBe(false) expect("agentDir" in createAgentSessionOptions).toBe(false)
expect(createAgentSessionOptions.resourceLoader).toBeDefined() expect(createAgentSessionOptions.resourceLoader).toBeDefined()
expect(typeof sessionCompactHandlerFromCapturedOptions()).toBe("function")
agent.dispose()
})
test("hydrates initial entries into the Pi session manager", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
initialEntries: [
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "stored hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
{
id: "entry-2",
sequence: 2,
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text: "stored reply" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:01.000Z"),
},
],
})
const events = collectEvents(
agent.ask({
message: "hello",
}),
)
await sessionCreationStarted
if (!isRecord(createAgentSessionOptions)) {
throw new Error("createAgentSession options were not captured")
}
const sessionManager = createAgentSessionOptions.sessionManager
if (!(sessionManager instanceof FakeSessionManager)) {
throw new Error("session manager was not hydrated by PiQueryAgent")
}
const context = sessionManager.buildSessionContext()
if (!isRecord(context) || !Array.isArray(context.messages)) {
throw new Error("session context messages were not captured")
}
expect(context.messages[0]).toEqual({
role: "user",
content: "stored hello",
timestamp: new Date("2026-06-15T00:00:00.000Z").getTime(),
})
expect(context.messages[1]).toMatchObject({
role: "assistant",
provider: "openrouter",
model: "z-ai/glm-4.7-flash",
stopReason: "stop",
timestamp: new Date("2026-06-15T00:00:01.000Z").getTime(),
})
releaseSessionCreation()
await promptStarted
releasePrompt()
expect(await events).toEqual([{ type: "done" }])
agent.dispose()
})
test("emits Pi compaction events for the active conversation", async () => {
const recordedCompactions: unknown[] = []
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
})
agent.addEventListener(QueryAgentEvent.Compaction, (event) => {
recordedCompactions.push(event)
})
const events = collectEvents(
agent.ask({
conversationId: "conversation-1",
message: "hello",
}),
)
await sessionCreationStarted
releaseSessionCreation()
await promptStarted
const handler = sessionCompactHandlerFromCapturedOptions()
await handler({
type: "session_compact",
fromExtension: false,
compactionEntry: {
type: "compaction",
id: "pi-compaction-1",
timestamp: "2026-06-15T00:00:00.000Z",
summary: "The user prefers concise updates.",
firstKeptEntryId: "pi-entry-7",
tokensBefore: 1234,
details: { reason: "threshold" },
},
})
expect(recordedCompactions).toEqual([
{
type: QueryAgentEvent.Compaction,
conversationId: "conversation-1",
summary: "The user prefers concise updates.",
firstKeptEntryId: "pi-entry-7",
compactedEntryRange: null,
tokensBefore: 1234,
details: { reason: "threshold" },
fromExtension: false,
},
])
releasePrompt()
expect(await events).toEqual([{ type: "done" }])
expect(unsubscribeCalls).toBe(1)
agent.dispose()
})
test("emits Freya coverage through the entry before Pi's kept boundary", async () => {
const recordedCompactions: unknown[] = []
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
initialEntries: [
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "old hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
{
id: "entry-2",
sequence: 2,
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text: "kept reply" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:01.000Z"),
},
],
})
agent.addEventListener(QueryAgentEvent.Compaction, (event) => {
recordedCompactions.push(event)
})
const events = collectEvents(
agent.ask({
conversationId: "conversation-1",
message: "hello",
}),
)
await sessionCreationStarted
await extensionHandlerFromCapturedOptions("session_before_compact")({
type: "session_before_compact",
preparation: {
firstKeptEntryId: "message-2",
},
branchEntries: [{ id: "message-1" }, { id: "message-2" }],
})
await extensionHandlerFromCapturedOptions("session_compact")({
type: "session_compact",
fromExtension: false,
compactionEntry: {
type: "compaction",
id: "pi-compaction-1",
timestamp: "2026-06-15T00:00:00.000Z",
summary: "Old hello was discussed.",
firstKeptEntryId: "message-2",
tokensBefore: 1234,
},
})
expect(recordedCompactions).toEqual([
{
type: QueryAgentEvent.Compaction,
conversationId: "conversation-1",
summary: "Old hello was discussed.",
firstKeptEntryId: "message-2",
compactedEntryRange: {
startSequence: 1,
endSequence: 1,
},
tokensBefore: 1234,
details: undefined,
fromExtension: false,
},
])
releaseSessionCreation()
await promptStarted
releasePrompt()
expect(await events).toEqual([{ type: "done" }])
agent.dispose() agent.dispose()
}) })
@@ -500,7 +175,9 @@ 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({
toolbox: createStubToolbox(), sessionManager: createStubSessionManager(),
modelProvider: "mock",
modelId: "mock-model",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
}) })
@@ -518,6 +195,7 @@ describe("PiQueryAgent", () => {
const events = collectEvents( const events = collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "hello", message: "hello",
}), }),
) )
@@ -536,7 +214,9 @@ 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({
toolbox: createStubToolbox(), sessionManager: createStubSessionManager(),
modelProvider: "mock",
modelId: "mock-model",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
}) })
@@ -556,6 +236,7 @@ describe("PiQueryAgent", () => {
const events = collectEvents( const events = collectEvents(
agent.ask({ agent.ask({
userId: "user-1",
message: "hello", message: "hello",
}), }),
) )
@@ -572,113 +253,20 @@ describe("PiQueryAgent", () => {
}) })
}) })
async function collectEvents( async function collectEvents(events: AsyncIterable<QueryAgentEvent>): Promise<QueryAgentEvent[]> {
events: AsyncIterable<QueryAgentStreamEvent>, const result: QueryAgentEvent[] = []
): Promise<QueryAgentStreamEvent[]> {
const result: QueryAgentStreamEvent[] = []
for await (const event of events) { for await (const event of events) {
result.push(event) result.push(event)
} }
return result return result
} }
function createStubToolbox(): QueryAgentToolbox { function createStubSessionManager(): UserSessionManager {
return { return {
async listSources(): Promise<never> { async getOrCreate(): Promise<never> {
throw new Error("not used") throw new Error("not used")
}, },
async getContext(): Promise<never> { } as unknown as UserSessionManager
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 sessionCompactHandlerFromCapturedOptions(): CapturedExtensionHandler {
return extensionHandlerFromCapturedOptions("session_compact")
}
function extensionHandlerFromCapturedOptions(eventName: string): CapturedExtensionHandler {
if (!isRecord(createAgentSessionOptions)) {
throw new Error("createAgentSession options were not captured")
}
const resourceLoader = createAgentSessionOptions.resourceLoader
if (!isCapturedResourceLoader(resourceLoader)) {
throw new Error("resourceLoader was not captured")
}
const extensionsResult = resourceLoader.getExtensions()
if (!isRecord(extensionsResult) || !Array.isArray(extensionsResult.extensions)) {
throw new Error("extensions were not captured")
}
const extension = extensionsResult.extensions[0]
if (!isCapturedExtension(extension)) {
throw new Error("compaction extension was not captured")
}
const handlers = extension.handlers.get(eventName)
const handler = handlers?.[0]
if (!handler) {
throw new Error(`${eventName} handler was not captured`)
}
return handler
}
function isCapturedResourceLoader(value: unknown): value is CapturedResourceLoader {
return isRecord(value) && typeof value.getExtensions === "function"
}
function isCapturedExtension(value: unknown): value is CapturedExtension {
return isRecord(value) && value.handlers instanceof Map
}
function isDefaultResourceLoaderOptions(
value: unknown,
): value is CapturedDefaultResourceLoaderOptions {
return (
isRecord(value) &&
(value.extensionFactories === undefined ||
(Array.isArray(value.extensionFactories) &&
value.extensionFactories.every(isCapturedExtensionFactory)))
)
}
function isCapturedExtensionFactory(value: unknown): value is CapturedExtensionFactory {
return typeof value === "function"
}
function modelFromMessages(messages: unknown[]): { provider: string; modelId: string } | null {
let model: { provider: string; modelId: string } | null = null
for (const message of messages) {
if (!isRecord(message)) continue
if (message.role !== "assistant") continue
if (typeof message.provider !== "string" || typeof message.model !== "string") continue
model = {
provider: message.provider,
modelId: message.model,
}
}
return model
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {

View File

@@ -1,119 +1,72 @@
import type { import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"
AgentSessionEvent,
ExtensionFactory,
SessionEntry,
} from "@earendil-works/pi-coding-agent"
import { import {
AuthStorage, AuthStorage,
createAgentSession, createAgentSession,
DefaultResourceLoader,
ModelRegistry, ModelRegistry,
SessionManager,
SettingsManager, SettingsManager,
} from "@earendil-works/pi-coding-agent" } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts" import type { UserSessionManager } from "../session/index.ts"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts" import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
import defaultSystemPrompt from "./prompts/system.txt" import defaultSystemPrompt from "./prompts/system.txt"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactedEntryRange,
type QueryAgentCompactionEvent,
type QueryAgentConversationEntryRef,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
import { createSessionManager } from "./session-manager.ts"
import { createFreyaAgentTools, FREYA_AGENT_TOOL_NAMES } from "./tools.ts" import { createFreyaAgentTools, FREYA_AGENT_TOOL_NAMES } from "./tools.ts"
type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"] type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"]
type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }> type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>
type PiAgentMessage = PiMessageEndEvent["message"] type PiAgentMessage = PiMessageEndEvent["message"]
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }> type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
type PiSessionManager = ReturnType<typeof createSessionManager>
type PiSessionMessage = Parameters<PiSessionManager["appendMessage"]>[0]
export interface PiQueryAgentConfig { export interface PiQueryAgentConfig {
toolbox: QueryAgentToolbox sessionManager: UserSessionManager
modelProvider: string
modelId: string
apiKey?: string apiKey?: string
cwd?: string cwd?: string
systemPrompt?: string systemPrompt?: string
initialEntries?: ConversationStorageEntry[]
} }
export const PI_MODEL_PROVIDER = "openrouter"
export const PI_MODEL_ID = "z-ai/glm-4.7-flash"
export class PiQueryAgent implements QueryAgent { export class PiQueryAgent implements QueryAgent {
private readonly toolbox: QueryAgentToolbox private readonly sessionManager: UserSessionManager
private readonly cwd: string private readonly cwd: string
private readonly systemPrompt: string private readonly systemPrompt: string
private readonly modelProvider: string
private readonly modelId: string
private readonly apiKey: string | undefined private readonly apiKey: string | undefined
private readonly initialEntries: ConversationStorageEntry[] private readonly sessions = new Map<string, PiSession>()
private readonly eventListeners = createQueryAgentEventListeners() private readonly pendingSessions = new Map<string, Promise<PiSession>>()
private session: PiSession | null = null private readonly activeRuns = new Map<string, symbol>()
private pendingSession: Promise<PiSession> | null = null
/**
* Conversation currently receiving Pi events for an active ask().
*
* Pi's compaction hook fires from the SDK session rather than from our
* QueryAgent call stack, so the hook reads this value to attach the
* compaction summary to the right Freya conversation. null means no active
* run; "" means a run is active but no Freya conversation id was supplied.
*/
private activeConversationId: string | null = null
/**
* Freya entry for the user message currently being handed to Pi.
*
* ConversationRecordingQueryAgent appends the user message before calling
* PiQueryAgent. Pi later persists its own copy of that user message into its
* SessionManager, and this one-shot reference lets us map Pi's generated
* session entry id back to the Freya sequence.
*/
private activeUserMessageEntry: QueryAgentConversationEntryRef | null = null
/**
* Maps Pi SessionManager entry ids to Freya conversation sequences.
*
* Pi compaction reports boundaries with Pi entry ids, while our DB replay
* logic uses monotonically increasing Freya sequences. This map is the bridge
* that lets us translate Pi's firstKeptEntryId into a compacted entry range.
*/
private readonly piEntryConversationSequences = new Map<string, number>()
private disposed = false
constructor(config: PiQueryAgentConfig) { constructor(config: PiQueryAgentConfig) {
this.toolbox = config.toolbox this.sessionManager = config.sessionManager
this.modelProvider = config.modelProvider
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.initialEntries = config.initialEntries ?? []
} }
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> { async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
if (this.activeConversationId !== null) { if (this.activeRuns.has(input.userId)) {
yield { yield {
type: "error", type: "error",
message: "A query is already running", message: "A query is already running for this user",
} }
return return
} }
this.activeConversationId = input.conversationId ?? "" const run = Symbol(input.userId)
this.activeUserMessageEntry = input.userMessageEntry ?? null this.activeRuns.set(input.userId, run)
let session: PiSession let session: PiSession
try { try {
session = await this.getOrCreateSession() session = await this.getOrCreateSession(input.userId)
} catch (err) { } catch (err) {
this.activeConversationId = null this.clearActiveRun(input.userId, run)
this.activeUserMessageEntry = null
yield { yield {
type: "error", type: "error",
message: `Failed to create query session: ${errorMessage(err)}`, message: `Failed to create query session: ${errorMessage(err)}`,
@@ -121,11 +74,11 @@ export class PiQueryAgent implements QueryAgent {
return return
} }
const events: QueryAgentStreamEvent[] = [] const events: QueryAgentEvent[] = []
let closed = false let closed = false
let wake: (() => void) | null = null let wake: (() => void) | null = null
function push(event: QueryAgentStreamEvent): void { function push(event: QueryAgentEvent): void {
events.push(event) events.push(event)
if (wake) { if (wake) {
wake() wake()
@@ -134,7 +87,7 @@ export class PiQueryAgent implements QueryAgent {
} }
let runFailed = false let runFailed = false
function pushRunEvent(event: QueryAgentStreamEvent): void { function pushRunEvent(event: QueryAgentEvent): void {
if (event.type === "error") { if (event.type === "error") {
if (runFailed) return if (runFailed) return
runFailed = true runFailed = true
@@ -154,8 +107,7 @@ export class PiQueryAgent implements QueryAgent {
this.handlePiEvent(event, pushRunEvent) this.handlePiEvent(event, pushRunEvent)
}) })
session void this.runPrompt(session, input)
.prompt(input.message)
.then(() => { .then(() => {
if (runFailed) return if (runFailed) return
pushRunEvent({ type: "done" }) pushRunEvent({ type: "done" })
@@ -165,8 +117,7 @@ export class PiQueryAgent implements QueryAgent {
}) })
.finally(() => { .finally(() => {
unsubscribe() unsubscribe()
this.activeConversationId = null this.clearActiveRun(input.userId, run)
this.activeUserMessageEntry = null
close() close()
}) })
@@ -183,256 +134,88 @@ 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 {
this.disposed = true for (const session of this.sessions.values()) {
this.session?.dispose() session.dispose()
this.session = null }
this.pendingSession = null this.sessions.clear()
this.activeConversationId = null this.pendingSessions.clear()
this.activeUserMessageEntry = null this.activeRuns.clear()
this.clearEventListeners()
} }
addEventListener<T extends QueryAgentEvent>( private clearActiveRun(userId: string, run: symbol): void {
type: T, if (this.activeRuns.get(userId) === run) {
listener: QueryAgentEventListener<T>, this.activeRuns.delete(userId)
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
} }
} }
private async getOrCreateSession(): Promise<PiSession> { private async getOrCreateSession(userId: string): Promise<PiSession> {
if (this.disposed) { const existing = this.sessions.get(userId)
throw new Error("Query agent is disposed") if (existing) return existing
}
if (this.session) return this.session const pending = this.pendingSessions.get(userId)
const pending = this.pendingSession
if (pending) return pending if (pending) return pending
const promise = this.createSession() const promise = this.createSession(userId)
this.pendingSession = promise this.pendingSessions.set(userId, promise)
try { try {
const session = await promise const session = await promise
if (this.disposed) { this.sessions.set(userId, session)
session.dispose()
throw new Error("Query agent is disposed")
}
this.session = session
return session return session
} finally { } finally {
if (this.pendingSession === promise) { this.pendingSessions.delete(userId)
this.pendingSession = null
}
} }
} }
private async createSession(): Promise<PiSession> { private async createSession(userId: string): 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(PI_MODEL_PROVIDER, this.apiKey) authStorage.setRuntimeApiKey(this.modelProvider, this.apiKey)
} }
const modelRegistry = ModelRegistry.inMemory(authStorage) const modelRegistry = ModelRegistry.inMemory(authStorage)
const model = modelRegistry.find(PI_MODEL_PROVIDER, PI_MODEL_ID) const model = modelRegistry.find(this.modelProvider, this.modelId)
if (!model) { if (!model) {
throw new Error(`Pi model not found: ${PI_MODEL_PROVIDER}/${PI_MODEL_ID}`) throw new Error(`Pi model not found: ${this.modelProvider}/${this.modelId}`)
} }
const resourceLoader = new DefaultResourceLoader({
cwd: this.cwd,
agentDir: this.cwd,
settingsManager,
systemPrompt: this.systemPrompt,
extensionFactories: [this.createCompactionExtension()],
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
})
await resourceLoader.reload()
const sessionManager = this.createMappedSessionManager()
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: this.cwd, cwd: this.cwd,
authStorage, authStorage,
modelRegistry, modelRegistry,
model, model,
resourceLoader, resourceLoader: new InMemoryResourceLoader(this.systemPrompt),
settingsManager, settingsManager,
sessionManager, sessionManager: SessionManager.inMemory(this.cwd),
noTools: "builtin", noTools: "builtin",
customTools: createFreyaAgentTools({ customTools: createFreyaAgentTools({
toolbox: this.toolbox, userId,
sessionManager: this.sessionManager,
}), }),
tools: FREYA_AGENT_TOOL_NAMES, tools: [...FREYA_AGENT_TOOL_NAMES],
}) })
return session return session
} }
/** private async runPrompt(session: PiSession, input: QueryAgentAsk): Promise<void> {
* Creates Pi's SessionManager and records Pi-id -> Freya-sequence mappings. await session.prompt(input.message)
*
* Hydrated DB messages are mapped through createSessionManager's callback.
* Live user messages are mapped by wrapping appendMessage(), because Pi owns
* the generated session entry id for messages written during prompt handling.
*/
private createMappedSessionManager(): PiSessionManager {
this.piEntryConversationSequences.clear()
const sessionManager = createSessionManager({
cwd: this.cwd,
entries: this.initialEntries,
modelProvider: PI_MODEL_PROVIDER,
modelId: PI_MODEL_ID,
onMessageEntryAppended: (piEntryId, entry) => {
this.piEntryConversationSequences.set(piEntryId, entry.sequence)
},
})
const appendMessage = sessionManager.appendMessage.bind(sessionManager)
sessionManager.appendMessage = (message: PiSessionMessage): string => {
const piEntryId = appendMessage(message)
const sequence = this.liveConversationSequenceForMessage(message)
if (sequence !== null) {
this.piEntryConversationSequences.set(piEntryId, sequence)
}
return piEntryId
} }
return sessionManager private handlePiEvent(event: AgentSessionEvent, push: (event: QueryAgentEvent) => void): void {
}
/**
* Returns the Freya sequence for Pi's persisted live user message.
*
* We only map user messages here because they are the messages Freya writes
* before invoking Pi. Assistant/tool entries are recorded from the stream
* outside Pi's SessionManager and do not have a stable live Pi id available
* at the storage boundary.
*/
private liveConversationSequenceForMessage(message: PiSessionMessage): number | null {
if (message.role !== "user") return null
const entry = this.activeUserMessageEntry
this.activeUserMessageEntry = null
if (!entry) return null
return entry.sequence
}
/**
* Installs the minimal Pi extension used to observe compaction.
*
* session_before_compact gives us the full branch plus firstKeptEntryId, so
* we translate that boundary before Pi writes the compaction entry. The later
* session_compact event carries the saved summary, which we forward with the
* cached Freya compacted entry range.
*/
private createCompactionExtension(): ExtensionFactory {
return (pi) => {
/**
* Temporary handoff between Pi's before/after compaction hooks.
*
* session_compact receives the saved compaction entry, not the original
* branch entries needed for boundary translation.
*/
let pendingCompactedEntryRange: QueryAgentCompactedEntryRange | null = null
pi.on("session_before_compact", async (event) => {
pendingCompactedEntryRange = this.compactedEntryRangeBeforePiEntry(
event.branchEntries,
event.preparation.firstKeptEntryId,
)
})
pi.on("session_compact", async (event) => {
const conversationId = this.activeConversationId
if (!conversationId) return
const entry = event.compactionEntry
const compactedEntryRange = pendingCompactedEntryRange
pendingCompactedEntryRange = null
const compactionEvent: QueryAgentCompactionEvent = {
type: QueryAgentEvent.Compaction,
conversationId,
summary: entry.summary,
firstKeptEntryId: entry.firstKeptEntryId,
compactedEntryRange,
tokensBefore: entry.tokensBefore,
details: entry.details,
fromExtension: event.fromExtension,
}
await this.emitEvent(compactionEvent)
})
}
}
/**
* Returns the Freya entry range compacted before a Pi session entry.
*
* Pi keeps firstKeptEntryId and everything after it as raw context. Therefore
* the summary covers only mapped entries before that Pi entry. If none of
* those entries map back to Freya, we return null so storage can avoid
* recording a summary with an unsafe coverage range.
*/
private compactedEntryRangeBeforePiEntry(
branchEntries: SessionEntry[],
piEntryId: string,
): QueryAgentCompactedEntryRange | null {
let endSequence: number | null = null
for (const entry of branchEntries) {
if (entry.id === piEntryId) {
if (endSequence === null) return null
return {
startSequence: 1,
endSequence,
}
}
const sequence = this.piEntryConversationSequences.get(entry.id)
if (typeof sequence === "number") {
endSequence = sequence
}
}
return null
}
private async emitEvent<T extends QueryAgentEvent>(event: QueryAgentEventMap[T]): Promise<void> {
const listeners = this.listenersFor(event.type)
for (const listener of listeners) {
await listener(event)
}
}
private listenersFor<T extends QueryAgentEvent>(type: T): QueryAgentEventListeners[T] {
return this.eventListeners[type]
}
private clearEventListeners(): void {
for (const listeners of Object.values(this.eventListeners)) {
listeners.clear()
}
}
private handlePiEvent(
event: AgentSessionEvent,
push: (event: QueryAgentStreamEvent) => void,
): void {
switch (event.type) { switch (event.type) {
case "message_end": { case "message_end": {
const message = piAssistantMessageError(event.message) const message = piAssistantMessageError(event.message)
@@ -500,6 +283,7 @@ function piAssistantMessageError(message: PiAgentMessage): string | null {
case "toolUse": case "toolUse":
return null return null
} }
return null
default: default:
return null return null
} }

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,74 +1,23 @@
export interface QueryAgentAsk { export interface QueryAgentAsk {
userId: string
message: string message: string
conversationId?: string
userMessageEntry?: QueryAgentConversationEntryRef
} }
export type QueryAgentStreamEvent = export type QueryAgentEvent =
| { type: "conversation"; conversationId: string }
| { 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: "done" } | { type: "done" }
| { type: "error"; message: string } | { type: "error"; message: string }
export const QueryAgentEvent = {
Compaction: "compaction",
} as const
export type QueryAgentEvent = (typeof QueryAgentEvent)[keyof typeof QueryAgentEvent]
export interface QueryAgentConversationEntryRef {
id: string
sequence: number
}
export interface QueryAgentCompactedEntryRange {
startSequence: number
endSequence: number
}
export interface QueryAgentCompactionEvent {
type: typeof QueryAgentEvent.Compaction
conversationId: string
summary: string
firstKeptEntryId: string
compactedEntryRange: QueryAgentCompactedEntryRange | null
tokensBefore: number
details?: unknown
fromExtension: boolean
}
export interface QueryAgentEventMap {
[QueryAgentEvent.Compaction]: QueryAgentCompactionEvent
}
export type QueryAgentEventListener<T extends QueryAgentEvent> = (
event: QueryAgentEventMap[T],
) => void | Promise<void>
export type QueryAgentEventListeners = {
[T in QueryAgentEvent]: Set<QueryAgentEventListener<T>>
}
export function createQueryAgentEventListeners(): QueryAgentEventListeners {
return {
[QueryAgentEvent.Compaction]: new Set(),
}
}
export interface QueryAgent { export interface QueryAgent {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
addEventListener<T extends QueryAgentEvent>( disposeUser(userId: string): void
type: T,
listener: QueryAgentEventListener<T>,
): () => void
dispose(): void dispose(): void
} }
export interface QueryAgentResponse { export interface QueryAgentResponse {
message: string message: string
conversationId?: string
} }
export class QueryAgentError extends Error { export class QueryAgentError extends Error {
@@ -83,13 +32,9 @@ export async function collectQueryAgentResponse(
input: QueryAgentAsk, input: QueryAgentAsk,
): Promise<QueryAgentResponse> { ): Promise<QueryAgentResponse> {
let message = "" let message = ""
let conversationId: string | undefined
for await (const event of agent.ask(input)) { for await (const event of agent.ask(input)) {
switch (event.type) { switch (event.type) {
case "conversation":
conversationId = event.conversationId
break
case "text_delta": case "text_delta":
message += event.text message += event.text
break break
@@ -102,5 +47,5 @@ export async function collectQueryAgentResponse(
} }
} }
return { message, conversationId } return { message }
} }

View File

@@ -1,156 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { createSessionManager } from "./session-manager.ts"
describe("createSessionManager", () => {
test("hydrates user and assistant entries into Pi session context", () => {
const sessionManager = createSessionManager({
entries: [
entry({
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "hello" }],
},
}),
entry({
id: "entry-2",
sequence: 2,
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text: "hi there" }],
},
metadata: {
modelRun: {
route: "agent_query",
provider: "openrouter",
model: "stored-model",
},
},
}),
],
modelProvider: "openrouter",
modelId: "fallback-model",
})
const context = sessionManager.buildSessionContext()
expect(context.messages.map(roleOf)).toEqual(["user", "assistant"])
expect(textFromMessage(context.messages[0])).toBe("hello")
expect(textFromMessage(context.messages[1])).toBe("hi there")
expect(context.model).toEqual({
provider: "openrouter",
modelId: "stored-model",
})
})
test("uses the latest context summary and replays only uncovered raw entries", () => {
const sessionManager = createSessionManager({
entries: [
entry({
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "old question" }],
},
}),
entry({
id: "entry-2",
sequence: 2,
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text: "old answer" }],
},
}),
entry({
id: "entry-3",
sequence: 3,
kind: ConversationEntryKind.ContextSummary,
payload: {
covers: {
startSequence: 1,
endSequence: 2,
},
summary: {
durableFacts: ["The user is designing conversation storage."],
preferences: [],
decisions: ["Context compaction is stored as a conversation entry."],
openTasks: [],
importantDetails: [],
},
promptVersion: "test-v1",
},
}),
entry({
id: "entry-4",
sequence: 4,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "new question" }],
},
}),
],
modelProvider: "openrouter",
modelId: "fallback-model",
})
const context = sessionManager.buildSessionContext()
expect(context.messages.map(roleOf)).toEqual(["compactionSummary", "user"])
expect(textFromMessage(context.messages[0])).toContain(
"The user is designing conversation storage.",
)
expect(textFromMessage(context.messages[0])).toContain(
"Context compaction is stored as a conversation entry.",
)
expect(textFromMessage(context.messages[1])).toBe("new question")
})
})
function entry(
input: Omit<ConversationStorageEntry, "createdAt" | "metadata"> & {
createdAt?: Date
metadata?: ConversationStorageEntry["metadata"]
},
): ConversationStorageEntry {
return {
...input,
metadata: input.metadata ?? {},
createdAt: input.createdAt ?? new Date("2026-06-15T00:00:00.000Z"),
}
}
function roleOf(message: unknown): string | undefined {
if (!isRecord(message)) return undefined
return typeof message.role === "string" ? message.role : undefined
}
function textFromMessage(message: unknown): string {
if (!isRecord(message)) return ""
if (typeof message.summary === "string") return message.summary
const content = message.content
if (typeof content === "string") return content
if (!Array.isArray(content)) return ""
return content.map(textFromContentPart).join("")
}
function textFromContentPart(part: unknown): string {
if (!isRecord(part)) return ""
return typeof part.text === "string" ? part.text : ""
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -1,188 +0,0 @@
import { SessionManager } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import {
AssistantMessagePayload,
ContextSummaryPayload,
ConversationEntryKind,
UserMessagePayload,
} from "../conversations/types.ts"
type PiMessage = Parameters<SessionManager["appendMessage"]>[0]
type PiAssistantMessage = Extract<PiMessage, { role: "assistant" }>
export interface CreateSessionManagerInput {
cwd?: string
entries: ConversationStorageEntry[]
modelProvider: string
modelId: string
onMessageEntryAppended?: (piEntryId: string, entry: ConversationStorageEntry) => void
}
export function createSessionManager(input: CreateSessionManagerInput): SessionManager {
const sessionManager = SessionManager.inMemory(input.cwd ?? tmpdir())
const context = buildContextFromEntries(input.entries)
if (context.summary) {
sessionManager.appendCompaction(
context.summary.text,
"freya-db-context-start",
0,
{
conversationEntryId: context.summary.entry.id,
covers: context.summary.covers,
},
true,
)
}
for (const entry of context.entries) {
const message = messageForEntry(entry, input.modelProvider, input.modelId)
if (message) {
const piEntryId = sessionManager.appendMessage(message)
input.onMessageEntryAppended?.(piEntryId, entry)
}
}
return sessionManager
}
function buildContextFromEntries(entries: ConversationStorageEntry[]): {
summary?: { entry: ConversationStorageEntry; text: string; covers: unknown }
entries: ConversationStorageEntry[]
} {
const orderedEntries = [...entries].sort((left, right) => left.sequence - right.sequence)
const summaryEntry = latestContextSummaryEntry(orderedEntries)
if (!summaryEntry || summaryEntry.kind !== ConversationEntryKind.ContextSummary) {
return { entries: orderedEntries }
}
const payload = ContextSummaryPayload.assert(summaryEntry.payload)
const text = contextSummaryText(payload.summary)
const rawStartSequence = payload.covers.endSequence + 1
return {
summary: {
entry: summaryEntry,
text,
covers: payload.covers,
},
entries: orderedEntries.filter((entry) => entry.sequence >= rawStartSequence),
}
}
function latestContextSummaryEntry(
entries: ConversationStorageEntry[],
): ConversationStorageEntry | undefined {
let latest: ConversationStorageEntry | undefined
for (const entry of entries) {
if (entry.kind !== ConversationEntryKind.ContextSummary) continue
if (!latest || entry.sequence > latest.sequence) {
latest = entry
}
}
return latest
}
function messageForEntry(
entry: ConversationStorageEntry,
modelProvider: string,
modelId: string,
): PiMessage | null {
switch (entry.kind) {
case ConversationEntryKind.UserMessage: {
const payload = UserMessagePayload.assert(entry.payload)
return {
role: "user",
content: messagePartsText(payload.parts),
timestamp: entry.createdAt.getTime(),
}
}
case ConversationEntryKind.AssistantMessage: {
const payload = AssistantMessagePayload.assert(entry.payload)
return {
role: "assistant",
content: [{ type: "text", text: messagePartsText(payload.parts) }],
api: "anthropic-messages",
provider: entry.metadata.modelRun?.provider ?? modelProvider,
model: entry.metadata.modelRun?.model ?? modelId,
usage: zeroUsage(),
stopReason: "stop",
timestamp: entry.createdAt.getTime(),
} satisfies PiAssistantMessage
}
case ConversationEntryKind.Attachment:
case ConversationEntryKind.ContextSummary:
case ConversationEntryKind.SystemNote:
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
return null
}
}
function messagePartsText(
parts: Array<{ type: "text"; text: string } | { type: "json"; value: unknown }>,
): string {
return parts.map(messagePartText).join("\n")
}
function messagePartText(
part: { type: "text"; text: string } | { type: "json"; value: unknown },
): string {
switch (part.type) {
case "text":
return part.text
case "json":
return stringifyJson(part.value)
}
}
function contextSummaryText(summary: {
userIntent?: string
durableFacts: string[]
preferences: string[]
decisions: string[]
openTasks: string[]
importantDetails: string[]
}): string {
const sections: string[] = []
pushSection(sections, "User intent", summary.userIntent ? [summary.userIntent] : [])
pushSection(sections, "Durable facts", summary.durableFacts)
pushSection(sections, "Preferences", summary.preferences)
pushSection(sections, "Decisions", summary.decisions)
pushSection(sections, "Open tasks", summary.openTasks)
pushSection(sections, "Important details", summary.importantDetails)
return sections.join("\n\n")
}
function pushSection(sections: string[], title: string, values: string[]): void {
const trimmedValues = values.map((value) => value.trim()).filter(Boolean)
if (trimmedValues.length === 0) return
sections.push(`${title}:\n${trimmedValues.map((value) => `- ${value}`).join("\n")}`)
}
function stringifyJson(value: unknown): string {
return JSON.stringify(value, null, 2) ?? String(value)
}
function zeroUsage(): PiAssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
}
}

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 { defineTool } from "@earendil-works/pi-coding-agent"
import { type } from "arktype"
import { Type } from "typebox" 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 { interface CreateFreyaAgentToolsConfig {
toolbox: QueryAgentToolbox userId: string
sessionManager: UserSessionManager
} }
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context" 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_GET_FEED_ITEM_TOOL = "freya_get_feed_item"
export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_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,
FREYA_GET_CONTEXT_TOOL, FREYA_GET_CONTEXT_TOOL,
@@ -62,13 +30,16 @@ export const FREYA_AGENT_TOOL_NAMES = [
] ]
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({}, { additionalProperties: false }), parameters: Type.Object({}),
execute: async () => executeListSourcesTool(config.toolbox), execute: async () => executeDebugTool(debugTools, userId, FREYA_LIST_SOURCES_TOOL, {}),
}) })
const getContextTool = defineTool({ const getContextTool = defineTool({
@@ -76,8 +47,7 @@ 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"].',
@@ -87,23 +57,20 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
description: "Match mode. Defaults to prefix.", description: "Match mode. Defaults to prefix.",
}), }),
), ),
}, }),
{ additionalProperties: false }, execute: async (_toolCallId, params) =>
), 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." }),
}, }),
{ additionalProperties: false }, execute: async (_toolCallId, params) =>
), executeDebugTool(debugTools, userId, FREYA_GET_FEED_ITEM_TOOL, params),
execute: async (_toolCallId, params) => executeGetFeedItemTool(config.toolbox, params),
}) })
const queryContextTool = defineTool({ const queryContextTool = defineTool({
@@ -111,8 +78,7 @@ 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.",
}), }),
@@ -121,10 +87,8 @@ 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.",
}), }),
), ),
}, }),
{ additionalProperties: false }, execute: async (_toolCallId, params) => executeQueryContextTool(config, params),
),
execute: async (_toolCallId, params) => executeQueryContextTool(config.toolbox, params),
}) })
const listContextTool = defineTool({ const listContextTool = defineTool({
@@ -132,8 +96,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({}, { additionalProperties: false }), parameters: Type.Object({}),
execute: async () => executeListContextTool(config.toolbox), execute: async () => executeListContextTool(config),
}) })
const getSourceDataTool = defineTool({ const getSourceDataTool = defineTool({
@@ -141,8 +105,7 @@ 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.",
}), }),
@@ -151,10 +114,8 @@ 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.",
}), }),
), ),
}, }),
{ additionalProperties: false }, execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
),
execute: async (_toolCallId, params) => executeGetSourceDataTool(config.toolbox, params),
}) })
const executeActionTool = defineTool({ const executeActionTool = defineTool({
@@ -162,8 +123,7 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
label: "Execute FREYA Action", label: "Execute FREYA Action",
description: description:
"Execute an available FREYA source action immediately without creating a proposal.", "Execute an available FREYA source action immediately without creating a proposal.",
parameters: Type.Object( parameters: Type.Object({
{
sourceId: Type.String({ description: "Source ID that should execute the action." }), sourceId: Type.String({ description: "Source ID that should execute the action." }),
actionId: Type.String({ description: "Source action ID to execute." }), actionId: Type.String({ description: "Source action ID to execute." }),
params: Type.Optional( params: Type.Optional(
@@ -171,10 +131,8 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
description: "Parameters to pass to the source action.", description: "Parameters to pass to the source action.",
}), }),
), ),
}, }),
{ additionalProperties: false }, execute: async (_toolCallId, params) => executeActionToolCall(config, params),
),
execute: async (_toolCallId, params) => executeActionToolCall(config.toolbox, params),
}) })
return [ return [
@@ -188,57 +146,166 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
] ]
} }
async function executeListSourcesTool(toolbox: QueryAgentToolbox) { async function executeDebugTool(
return toolbox.listSources() 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) { async function executeQueryContextTool(
const params = GetContextToolParams(rawParams) config: CreateFreyaAgentToolsConfig,
if (params instanceof type.errors) { params: { question: string; feedItemId?: string },
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: {},
}
}
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 {
content: [
return toolbox.getContext(params.key, match) {
} type: "text" as const,
text: JSON.stringify({
async function executeGetFeedItemTool(toolbox: QueryAgentToolbox, rawParams: unknown) { ok: true,
const params = GetFeedItemToolParams(rawParams) ...actionExecution,
if (params instanceof type.errors) { }),
throw new Error(params.summary) },
],
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

@@ -1,373 +0,0 @@
import { and, asc, desc, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts"
import type {
AssistantMessagePayload,
AttachmentPayload,
ContextSummaryPayload,
ConversationEntryKind as ConversationEntryKindType,
ConversationEntryMetadata,
ConversationEntryPayload,
ConversationEntryVisibility as ConversationEntryVisibilityType,
GenericObjectPayload,
UserMessagePayload,
} from "./types.ts"
import {
conversationEntries,
conversations as conversationsTable,
files,
user,
} from "../db/schema.ts"
import {
ConversationEntryMetadata as ConversationEntryMetadataSchema,
AssistantMessagePayload as AssistantMessagePayloadSchema,
AttachmentPayload as AttachmentPayloadSchema,
ConversationEntryKind,
ConversationEntryKindInput,
ConversationEntryVisibility,
ConversationEntryVisibilityInput,
ContextSummaryPayload as ContextSummaryPayloadSchema,
GenericObjectPayload as GenericObjectPayloadSchema,
UserMessagePayload as UserMessagePayloadSchema,
} from "./types.ts"
export type ConversationRow = typeof conversationsTable.$inferSelect
export type ConversationEntryRow = typeof conversationEntries.$inferSelect
export type FileRow = typeof files.$inferSelect
export interface CreateFileInput {
storageKey: string
originalName?: string
mimeType: string
sizeBytes: number
metadata?: Record<string, unknown>
}
export interface AppendAttachmentEntryInput {
file: CreateFileInput
payload: AttachmentPayload
visibility?: ConversationEntryVisibilityType
metadata?: ConversationEntryMetadata
}
export interface AppendAttachmentEntryResult {
file: FileRow
entry: ConversationEntryRow
}
interface AppendConversationEntryBase {
visibility?: ConversationEntryVisibilityType
metadata?: ConversationEntryMetadata
}
export type AppendConversationEntryInput =
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.UserMessage
payload: UserMessagePayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.AssistantMessage
payload: AssistantMessagePayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.Attachment
payload: AttachmentPayload
fileId: string
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.ContextSummary
payload: ContextSummaryPayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind:
| typeof ConversationEntryKind.ToolCall
| typeof ConversationEntryKind.ToolResult
| typeof ConversationEntryKind.SystemNote
payload: GenericObjectPayload
fileId?: never
})
export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibilityType
}
export function conversations(db: Database, userId: string) {
return {
async createConversation(): Promise<ConversationRow> {
return insertConversation(db, userId)
},
async getOrCreateConversation(): Promise<ConversationRow> {
return db.transaction(async (tx) => {
await requireUserForUpdate(tx, userId)
const existing = await latestConversation(tx, userId)
if (existing) return existing
return insertConversation(tx, userId)
})
},
async createFile(input: CreateFileInput): Promise<FileRow> {
return insertFile(db, userId, input)
},
async appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationEntryRow> {
const kind = ConversationEntryKindInput.assert(input.kind)
const visibility = ConversationEntryVisibilityInput.assert(
input.visibility ?? defaultVisibilityForKind(kind),
)
const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
let fileId: string | null = null
if (input.kind === ConversationEntryKind.Attachment) {
fileId = input.fileId
await requireFile(db, userId, fileId)
}
const rows = await db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
.insert(conversationEntries)
.values({
conversationId,
sequence,
kind,
visibility,
fileId,
payload,
metadata,
})
.returning()
await touchConversation(tx, userId, conversationId)
return rows
})
return requireRow(rows)
},
async appendAttachmentEntry(
conversationId: string,
input: AppendAttachmentEntryInput,
): Promise<AppendAttachmentEntryResult> {
const payload = AttachmentPayloadSchema.assert(input.payload)
const visibility = ConversationEntryVisibilityInput.assert(
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
)
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
return db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
const file = await insertFile(tx, userId, input.file)
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
.insert(conversationEntries)
.values({
conversationId,
sequence,
kind: ConversationEntryKind.Attachment,
visibility,
fileId: file.id,
payload,
metadata,
})
.returning()
await touchConversation(tx, userId, conversationId)
return {
file,
entry: requireRow(rows),
}
})
},
async listEntries(
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
await requireConversation(db, userId, conversationId)
if (params.visibility) {
return db
.select()
.from(conversationEntries)
.where(
and(
eq(conversationEntries.conversationId, conversationId),
eq(conversationEntries.visibility, params.visibility),
),
)
.orderBy(asc(conversationEntries.sequence))
}
return db
.select()
.from(conversationEntries)
.where(eq(conversationEntries.conversationId, conversationId))
.orderBy(asc(conversationEntries.sequence))
},
}
}
function payloadForKind(
kind: ConversationEntryKindType,
payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload {
switch (kind) {
case ConversationEntryKind.UserMessage:
return UserMessagePayloadSchema.assert(payload)
case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayloadSchema.assert(payload)
case ConversationEntryKind.Attachment:
return AttachmentPayloadSchema.assert(payload)
case ConversationEntryKind.ContextSummary:
return ContextSummaryPayloadSchema.assert(payload)
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote:
return GenericObjectPayloadSchema.assert(payload)
}
}
async function requireUserForUpdate(db: Database, userId: string): Promise<void> {
const rows = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
.for("update")
requireRow(rows, `User not found: ${userId}`)
}
async function requireConversation(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function requireConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
.for("update")
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(eq(conversationsTable.userId, userId))
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
.limit(1)
return rows[0] ?? null
}
async function insertConversation(db: Database, userId: string): Promise<ConversationRow> {
const rows = await db
.insert(conversationsTable)
.values({
userId,
})
.returning()
return requireRow(rows)
}
async function requireFile(db: Database, userId: string, fileId: string): Promise<FileRow> {
const rows = await db
.select()
.from(files)
.where(and(eq(files.id, fileId), eq(files.userId, userId)))
.limit(1)
return requireRow(rows, `File not found: ${fileId}`)
}
async function insertFile(db: Database, userId: string, input: CreateFileInput): Promise<FileRow> {
const rows = await db
.insert(files)
.values({
userId,
storageKey: input.storageKey,
originalName: input.originalName ?? null,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
metadata: input.metadata ?? {},
})
.returning()
return requireRow(rows)
}
async function touchConversation(
db: Database,
userId: string,
conversationId: string,
): Promise<void> {
await db
.update(conversationsTable)
.set({ updatedAt: new Date() })
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
}
async function nextSequence(db: Database, conversationId: string): Promise<number> {
const rows = await db
.select({ sequence: conversationEntries.sequence })
.from(conversationEntries)
.where(eq(conversationEntries.conversationId, conversationId))
.orderBy(desc(conversationEntries.sequence))
.limit(1)
return (rows[0]?.sequence ?? 0) + 1
}
function requireRow<T>(rows: T[], message = "Expected database row"): T {
const row = rows[0]
if (!row) throw new Error(message)
return row
}
function defaultVisibilityForKind(
kind: ConversationEntryKindType,
): ConversationEntryVisibilityType {
switch (kind) {
case ConversationEntryKind.UserMessage:
case ConversationEntryKind.AssistantMessage:
case ConversationEntryKind.Attachment:
return ConversationEntryVisibility.UserVisible
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.ContextSummary:
case ConversationEntryKind.SystemNote:
return ConversationEntryVisibility.Internal
}
}

View File

@@ -1,146 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
AttachmentType,
AttachmentPayload,
ContextSummaryPayload,
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
} from "./types.ts"
describe("conversation entry schemas", () => {
test("parses valid user message payloads", () => {
const payload = UserMessagePayload.assert({
role: "user",
parts: [{ type: "text", text: "hello" }],
})
expect(payload).toEqual({
role: "user",
parts: [{ type: "text", text: "hello" }],
})
})
test("rejects user message payloads with the wrong role", () => {
expect(() =>
UserMessagePayload.assert({
role: "assistant",
parts: [{ type: "text", text: "hello" }],
}),
).toThrow()
})
test("rejects user message payloads with no parts", () => {
expect(() =>
UserMessagePayload.assert({
role: "user",
parts: [],
}),
).toThrow()
})
test("parses valid attachment payloads", () => {
const payload = AttachmentPayload.assert({
role: "user",
name: "whiteboard.png",
mimeType: "image/png",
attachmentType: AttachmentType.Image,
caption: "whiteboard sketch",
})
expect(payload).toEqual({
role: "user",
name: "whiteboard.png",
mimeType: "image/png",
attachmentType: AttachmentType.Image,
caption: "whiteboard sketch",
})
})
test("rejects extra fields on structured payloads", () => {
expect(() =>
AttachmentPayload.assert({
role: "user",
name: "whiteboard.png",
mimeType: "image/png",
attachmentType: AttachmentType.Image,
fileId: "file-1",
}),
).toThrow()
})
test("parses context summary payloads", () => {
const payload = ContextSummaryPayload.assert({
covers: {
startSequence: 1,
endSequence: 12,
},
summary: {
userIntent: "Design message storage.",
durableFacts: [],
preferences: ["Keep the schema simple."],
decisions: ["Use conversation_entries as the timeline."],
openTasks: [],
importantDetails: [],
},
promptVersion: "conversation-summary-v1",
sourceEntryIds: ["entry-1", "entry-2"],
})
expect(payload).toMatchObject({
covers: {
startSequence: 1,
endSequence: 12,
},
promptVersion: "conversation-summary-v1",
})
})
test("allows generic object payloads for tool entries", () => {
const payload = GenericObjectPayload.assert({
toolCallId: "call-1",
toolName: "calendar.search",
input: { date: "2026-06-15" },
})
expect(payload).toEqual({
toolCallId: "call-1",
toolName: "calendar.search",
input: { date: "2026-06-15" },
})
})
test("rejects non-object generic payloads", () => {
expect(() => GenericObjectPayload.assert("done")).toThrow()
})
test("parses model run metadata and allows extra top-level metadata", () => {
const metadata = ConversationEntryMetadata.assert({
modelRun: {
route: "default-chat",
provider: "pi",
model: "pi-model",
inputTokens: 120,
outputTokens: 24,
},
traceId: "trace-1",
})
expect(metadata.modelRun?.model).toBe("pi-model")
expect(metadata.traceId).toBe("trace-1")
})
test("rejects invalid model run metadata", () => {
expect(() =>
ConversationEntryMetadata.assert({
modelRun: {
route: "default-chat",
provider: "pi",
model: "pi-model",
inputTokens: -1,
},
}),
).toThrow()
})
})

View File

@@ -1,136 +0,0 @@
import { type } from "arktype"
export const ConversationEntryKind = {
UserMessage: "user_message",
AssistantMessage: "assistant_message",
Attachment: "attachment",
ToolCall: "tool_call",
ToolResult: "tool_result",
ContextSummary: "context_summary",
SystemNote: "system_note",
} as const
export type ConversationEntryKind =
(typeof ConversationEntryKind)[keyof typeof ConversationEntryKind]
export const ConversationEntryVisibility = {
UserVisible: "user_visible",
Internal: "internal",
} as const
export type ConversationEntryVisibility =
(typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility]
export const AttachmentType = {
Image: "image",
Audio: "audio",
Video: "video",
Document: "document",
Other: "other",
} as const
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
export const ConversationEntryKindInput = type.enumerated(...Object.values(ConversationEntryKind))
export const ConversationEntryVisibilityInput = type.enumerated(
...Object.values(ConversationEntryVisibility),
)
export const AttachmentTypeInput = type.enumerated(...Object.values(AttachmentType))
const TextMessagePart = type({
"+": "reject",
type: "'text'",
text: "string",
})
const JsonMessagePart = type({
"+": "reject",
type: "'json'",
value: "unknown",
})
export const MessagePart = type.or(TextMessagePart, JsonMessagePart)
export type MessagePart = typeof MessagePart.infer
export const UserMessagePayload = type({
"+": "reject",
role: "'user'",
parts: MessagePart.array().atLeastLength(1),
})
export type UserMessagePayload = typeof UserMessagePayload.infer
export const AssistantMessagePayload = type({
"+": "reject",
role: "'assistant'",
parts: MessagePart.array().atLeastLength(1),
})
export type AssistantMessagePayload = typeof AssistantMessagePayload.infer
export const AttachmentPayload = type({
"+": "reject",
role: type.enumerated("user", "assistant"),
name: "string",
mimeType: "string",
attachmentType: AttachmentTypeInput,
"caption?": "string",
})
export type AttachmentPayload = typeof AttachmentPayload.infer
const ContextSummary = type({
"+": "reject",
"userIntent?": "string",
durableFacts: type.string.array(),
preferences: type.string.array(),
decisions: type.string.array(),
openTasks: type.string.array(),
importantDetails: type.string.array(),
})
export const ContextSummaryPayload = type({
"+": "reject",
covers: type({
"+": "reject",
startSequence: "number.integer >= 1",
endSequence: "number.integer >= 1",
}),
summary: ContextSummary,
promptVersion: "string",
"sourceEntryIds?": type.string.array(),
})
export type ContextSummaryPayload = typeof ContextSummaryPayload.infer
export const ModelRunMetadata = type({
"+": "reject",
route: "string",
provider: "string",
model: "string",
"contextSummaryEntryId?": "string",
"rawEntriesStartSequence?": "number.integer >= 1",
"rawEntriesEndSequence?": "number.integer >= 1",
"inputTokens?": "number.integer >= 0",
"outputTokens?": "number.integer >= 0",
"providerRequestId?": "string",
})
export type ModelRunMetadata = typeof ModelRunMetadata.infer
export const ConversationEntryMetadata = type({
"modelRun?": ModelRunMetadata,
"[string]": "unknown",
})
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer
export const GenericObjectPayload = type("Record<string, unknown>")
export type GenericObjectPayload = typeof GenericObjectPayload.infer
export type ConversationEntryPayload =
| UserMessagePayload
| AssistantMessagePayload
| AttachmentPayload
| ContextSummaryPayload
| GenericObjectPayload

View File

@@ -1,9 +1,6 @@
import { sql } from "drizzle-orm"
import { import {
boolean, boolean,
check,
customType, customType,
integer,
index, index,
jsonb, jsonb,
pgTable, pgTable,
@@ -13,14 +10,6 @@ import {
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "../conversations/types.ts"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Better Auth core tables // Better Auth core tables
// Re-exported from CLI-generated schema. // Re-exported from CLI-generated schema.
@@ -72,81 +61,6 @@ export const userSources = pgTable(
], ],
) )
// ---------------------------------------------------------------------------
// FREYA — conversations
// ---------------------------------------------------------------------------
export const conversations = pgTable(
"conversations",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at")
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => [index("conversations_user_id_updated_at_idx").on(t.userId, t.updatedAt)],
)
export const files = pgTable(
"files",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
originalName: text("original_name"),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
unique("files_storage_key_unique").on(t.storageKey),
index("files_user_id_created_at_idx").on(t.userId, t.createdAt),
],
)
export const conversationEntries = pgTable(
"conversation_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
sequence: integer("sequence").notNull(),
kind: text("kind").$type<ConversationEntryKind>().notNull(),
visibility: text("visibility")
.$type<ConversationEntryVisibilityType>()
.notNull()
.default(ConversationEntryVisibility.Internal),
fileId: uuid("file_id").references(() => files.id, { onDelete: "restrict" }),
payload: jsonb("payload").$type<ConversationEntryPayload>().notNull(),
metadata: jsonb("metadata").$type<ConversationEntryMetadata>().notNull().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
unique("conversation_entries_conversation_id_sequence_unique").on(t.conversationId, t.sequence),
index("conversation_entries_conversation_id_sequence_idx").on(t.conversationId, t.sequence),
index("conversation_entries_conversation_id_visibility_sequence_idx").on(
t.conversationId,
t.visibility,
t.sequence,
),
index("conversation_entries_kind_idx").on(t.kind),
index("conversation_entries_file_id_idx").on(t.fileId),
check(
"conversation_entries_attachment_file_id_check",
sql`(${t.kind} = 'attachment' and ${t.fileId} is not null) or (${t.kind} <> 'attachment' and ${t.fileId} is null)`,
),
],
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// FREYA — reminders source storage // FREYA — reminders source storage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -85,20 +85,6 @@ mock.module("../sources/user-sources.ts", () => ({
}), }),
})) }))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation() {
return { id: `conversation-${userId}` }
},
async listEntries() {
return []
},
async appendEntry() {
return { id: "entry-1", sequence: 1 }
},
}),
}))
const fakeDb = {} as Database const fakeDb = {} as Database
describe("GET /api/feed", () => { describe("GET /api/feed", () => {

View File

@@ -11,6 +11,7 @@ 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 ",
@@ -25,6 +26,7 @@ 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",
@@ -51,6 +53,25 @@ 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,6 +5,7 @@ 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
@@ -38,6 +39,7 @@ 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,6 +4,7 @@ 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"
@@ -34,11 +35,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,
@@ -62,9 +63,13 @@ 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")
@@ -115,7 +120,7 @@ function main() {
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware }) registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware }) registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerAgentHttpHandlers(app, { registerAgentHttpHandlers(app, {
sessionManager, queryAgent,
authSessionMiddleware, authSessionMiddleware,
}) })
if (isDebugMode) { if (isDebugMode) {
@@ -128,7 +133,7 @@ function main() {
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db }) registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
sessionManager.dispose() queryAgent.dispose()
await closeDb() await closeDb()
process.exit(0) process.exit(0)
}) })

View File

@@ -1,6 +1,7 @@
import type { FeedSource } from "@freya/core" 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 { 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

@@ -4,12 +4,9 @@ import { LocationSource } from "@freya/source-location"
import { WeatherSource } from "@freya/source-weatherkit" import { WeatherSource } from "@freya/source-weatherkit"
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { ConversationStorageEntry } from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { CredentialEncryptor } from "../lib/crypto.ts" import { CredentialEncryptor } from "../lib/crypto.ts"
import { import {
CredentialStorageUnavailableError, CredentialStorageUnavailableError,
@@ -24,8 +21,6 @@ import { UserSessionManager } from "./user-session-manager.ts"
* Key = userId (or "*" for a default), value = array of enabled sourceIds. * Key = userId (or "*" for a default), value = array of enabled sourceIds.
*/ */
const enabledByUser = new Map<string, string[]>() const enabledByUser = new Map<string, string[]>()
const conversationEntriesByUser = new Map<string, ConversationStorageEntry[]>()
const mockConversationCalls: Array<{ type: "getOrCreate" | "listEntries"; userId: string }> = []
/** Set which sourceIds are enabled for all users. */ /** Set which sourceIds are enabled for all users. */
function setEnabledSources(sourceIds: string[]) { function setEnabledSources(sourceIds: string[]) {
@@ -42,10 +37,6 @@ function getEnabledSourceIds(userId: string): string[] {
return enabledByUser.get(userId) ?? enabledByUser.get("*") ?? [] return enabledByUser.get(userId) ?? enabledByUser.get("*") ?? []
} }
function setConversationEntriesForUser(userId: string, entries: ConversationStorageEntry[]) {
conversationEntriesByUser.set(userId, entries)
}
/** /**
* Controls what `find()` returns in the mock. When `undefined` (the default), * Controls what `find()` returns in the mock. When `undefined` (the default),
* `find()` returns a standard enabled row. Set to a specific value (including * `find()` returns a standard enabled row. Set to a specific value (including
@@ -120,35 +111,6 @@ mock.module("../sources/user-sources.ts", () => ({
}), }),
})) }))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation(): Promise<{ id: string }> {
mockConversationCalls.push({ type: "getOrCreate", userId })
return { id: `conversation-${userId}` }
},
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
mockConversationCalls.push({ type: "listEntries", userId })
return conversationEntriesByUser.get(userId) ?? []
},
async appendEntry(
_conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
const entries = conversationEntriesByUser.get(userId) ?? []
const row: ConversationStorageEntry = {
id: `entry-${entries.length + 1}`,
sequence: entries.length + 1,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
conversationEntriesByUser.set(userId, [...entries, row])
return row
},
}),
}))
const fakeDb = { const fakeDb = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb), transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database } as unknown as Database
@@ -198,8 +160,6 @@ const weatherProvider: FeedSourceProvider = {
beforeEach(() => { beforeEach(() => {
enabledByUser.clear() enabledByUser.clear()
conversationEntriesByUser.clear()
mockConversationCalls.length = 0
mockFindResult = undefined mockFindResult = undefined
mockUpdateCredentialsCalls.length = 0 mockUpdateCredentialsCalls.length = 0
mockUpdateCredentialsError = null mockUpdateCredentialsError = null
@@ -216,31 +176,6 @@ describe("UserSessionManager", () => {
expect(session.engine).toBeDefined() expect(session.engine).toBeDefined()
}) })
test("getOrCreate eagerly loads conversation entries for the user session", async () => {
setEnabledSources([])
setConversationEntriesForUser("user-1", [
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "stored hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
])
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
await manager.getOrCreate("user-1")
expect(mockConversationCalls).toEqual([
{ type: "getOrCreate", userId: "user-1" },
{ type: "listEntries", userId: "user-1" },
])
})
test("getOrCreate returns same session for same user", async () => { test("getOrCreate returns same session for same user", async () => {
setEnabledSources(["freya.location"]) setEnabledSources(["freya.location"])
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] }) const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })

View File

@@ -8,21 +8,19 @@ import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { CredentialEncryptor } from "../lib/crypto.ts" import type { CredentialEncryptor } from "../lib/crypto.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { conversations } from "../conversations/storage.ts"
import { import {
CredentialStorageUnavailableError, CredentialStorageUnavailableError,
InvalidSourceConfigError, InvalidSourceConfigError,
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, type UserSessionAgentConfig } from "./user-session.ts" import { UserSession } 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 {
@@ -32,7 +30,6 @@ 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
@@ -41,7 +38,6 @@ 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 {
@@ -103,14 +99,6 @@ 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.
@@ -363,7 +351,6 @@ export class UserSessionManager {
private async createSession(userId: string): Promise<UserSession> { private async createSession(userId: string): Promise<UserSession> {
const enabledRows = await sources(this.db, userId).enabled() const enabledRows = await sources(this.db, userId).enabled()
const agentConfig = this.queryAgentConfigForUser(userId)
const promises: Promise<FeedSource>[] = [] const promises: Promise<FeedSource>[] = []
for (const row of enabledRows) { for (const row of enabledRows) {
@@ -375,7 +362,7 @@ export class UserSessionManager {
} }
if (promises.length === 0) { if (promises.length === 0) {
return this.initializedSession(userId, [], agentConfig) return new UserSession(userId, [], this.feedEnhancer)
} }
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
@@ -399,29 +386,7 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error) console.error("[UserSessionManager] Feed source provider failed:", error)
} }
return this.initializedSession(userId, feedSources, agentConfig) return new UserSession(userId, feedSources, this.feedEnhancer)
}
private queryAgentConfigForUser(userId: string): UserSessionAgentConfig {
return {
...(this.queryAgentConfig ?? {}),
conversationStorage: conversations(this.db, userId),
}
}
private async initializedSession(
userId: string,
sources: FeedSource[],
agentConfig: UserSessionAgentConfig,
): Promise<UserSession> {
const session = new UserSession(userId, sources, this.feedEnhancer, agentConfig)
try {
await session.initialize()
return session
} catch (err) {
session.destroy()
throw err
}
} }
/** /**

View File

@@ -3,13 +3,6 @@ import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@frey
import { LocationSource } from "@freya/source-location" import { LocationSource } from "@freya/source-location"
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import type {
ConversationStorage,
ConversationStorageEntry,
} from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource { function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
@@ -30,40 +23,6 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
} }
} }
class FakeConversationStorage implements ConversationStorage {
readonly calls: string[] = []
private readonly entries: ConversationStorageEntry[]
constructor(entries: ConversationStorageEntry[] = []) {
this.entries = entries
}
async getOrCreateConversation(): Promise<{ id: string }> {
this.calls.push("getOrCreateConversation")
return { id: "conversation-1" }
}
async appendEntry(
_conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
this.calls.push("appendEntry")
return {
id: "entry-appended",
sequence: 1,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
}
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
this.calls.push("listEntries")
return this.entries
}
}
describe("UserSession", () => { describe("UserSession", () => {
test("registers sources and starts engine", async () => { test("registers sources and starts engine", async () => {
const session = new UserSession("test-user", [ const session = new UserSession("test-user", [
@@ -99,41 +58,6 @@ 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("initialize loads conversation entries before exposing stored agent", async () => {
const storage = new FakeConversationStorage([
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "stored hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
])
const session = new UserSession("test-user", [createStubSource("test")], null, {
conversationStorage: storage,
})
expect(() => session.agent).toThrow("UserSession has not been initialized")
await session.initialize()
expect(storage.calls).toEqual(["getOrCreateConversation", "listEntries"])
expect(session.agent).toBeDefined()
})
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,50 +6,23 @@ 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 {
ConversationRecordingQueryAgent,
type ConversationStorage,
} from "../agent/conversation-recording-query-agent.ts"
import { PiQueryAgent, PI_MODEL_ID, PI_MODEL_PROVIDER } 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
conversationStorage?: ConversationStorage
}
export class UserSession { export class UserSession {
readonly userId: string readonly userId: string
readonly engine: FeedEngine readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null private readonly enhancer: FeedEnhancer | null
private readonly agentConfig: UserSessionAgentConfig | undefined
private queryAgent: QueryAgent | null = null
private initializePromise: Promise<void> | null = null
private initialized = false
private enhancedItems: FeedItem[] | null = null private enhancedItems: FeedItem[] | null = null
/** The FeedResult that enhancedItems was derived from. */ /** The FeedResult that enhancedItems was derived from. */
private enhancedSource: FeedResult | null = null private enhancedSource: FeedResult | null = null
private enhancingPromise: Promise<void> | null = null private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null private unsubscribe: (() => void) | null = null
constructor( constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
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
this.agentConfig = agentConfig
for (const source of sources) { for (const source of sources) {
this.sources.set(source.id, source) this.sources.set(source.id, source)
this.engine.register(source) this.engine.register(source)
@@ -62,44 +35,9 @@ export class UserSession {
}) })
} }
this.toolbox = new UserSessionQueryAgentToolbox(this)
if (!agentConfig?.conversationStorage) {
this.queryAgent = new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
})
this.initialized = true
}
this.engine.start() this.engine.start()
} }
get agent(): QueryAgent {
if (!this.queryAgent) {
throw new Error("UserSession has not been initialized")
}
return this.queryAgent
}
async initialize(): Promise<void> {
if (this.initialized) return
if (this.initializePromise) return this.initializePromise
const promise = this.initializeAgent()
this.initializePromise = promise
try {
await promise
this.initialized = true
} finally {
if (this.initializePromise === promise) {
this.initializePromise = null
}
}
}
/** /**
* Returns the current feed, refreshing if the engine cache expired. * Returns the current feed, refreshing if the engine cache expired.
* Enhancement runs eagerly on engine updates; this method awaits * Enhancement runs eagerly on engine updates; this method awaits
@@ -236,8 +174,6 @@ export class UserSession {
} }
destroy(): void { destroy(): void {
this.queryAgent?.dispose()
this.queryAgent = null
this.unsubscribe?.() this.unsubscribe?.()
this.unsubscribe = null this.unsubscribe = null
this.engine.stop() this.engine.stop()
@@ -246,38 +182,6 @@ export class UserSession {
this.enhancingPromise = null this.enhancingPromise = null
} }
private async initializeAgent(): Promise<void> {
if (this.queryAgent) return
const conversationStorage = this.agentConfig?.conversationStorage
if (!conversationStorage) {
this.queryAgent = new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
})
return
}
const conversation = await conversationStorage.getOrCreateConversation()
const entries = await conversationStorage.listEntries(conversation.id)
this.queryAgent = new ConversationRecordingQueryAgent({
agent: new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
initialEntries: entries,
}),
storage: conversationStorage,
defaultConversationId: conversation.id,
modelProvider: PI_MODEL_PROVIDER,
modelId: PI_MODEL_ID,
})
}
private invalidateEnhancement(): void { private invalidateEnhancement(): void {
this.enhancedItems = null this.enhancedItems = null
this.enhancedSource = null this.enhancedSource = null

View File

@@ -128,20 +128,6 @@ mock.module("../sources/user-sources.ts", () => ({
}, },
})) }))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation() {
return { id: `conversation-${userId}` }
},
async listEntries() {
return []
},
async appendEntry() {
return { id: "entry-1", sequence: 1 }
},
}),
}))
const fakeDb = { const fakeDb = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb), transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database } as unknown as Database

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://freya.chat/social-media-preview.jpg" }, { property: "og:image", content: "https://ael.is/social-media-preview.jpg" },
{ property: "og:url", content: "https://freya.chat" }, { property: "og:url", content: "https://ael.is" },
{ 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://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)) await new Promise((resolve) => setTimeout(resolve, 1000))
const emailRes = await resend.emails.send({ const emailRes = await resend.emails.send({
from: "Freya <no-reply@freya.chat>", from: "Freya <no-reply@ael.is>",
to: email, to: email,
template: { template: {
id: "waitlist-confirmation", id: "waitlist-confirmation",
@@ -380,6 +380,7 @@ 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://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. 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://freya.chat/sitemap.xml Sitemap: https://ael.is/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://freya.chat/</loc> <loc>https://ael.is/</loc>
</url> </url>
<url> <url>
<loc>https://freya.chat/privacy</loc> <loc>https://ael.is/privacy</loc>
</url> </url>
</urlset> </urlset>