Compare commits

..

1 Commits

Author SHA1 Message Date
be1bf43f7d refactor: split query agent toolbox 2026-06-15 20:49:36 +01:00
59 changed files with 2139 additions and 5576 deletions

View File

@@ -39,13 +39,4 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc. - Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- Commits: conventional commit format, title <= 50 chars - Commits: conventional commit format, title <= 50 chars
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`
## Nix
Use the Nix dev shell for project commands by default.
- Run repo tooling through `nix develop -c`, e.g. `nix develop -c bun test`.
- Use Bun exclusively inside the Nix shell.
- Do not use host `bun`, `node`, `tsc`, or package binaries for project tasks unless explicitly checking host behavior.
- Simple inspection commands like `rg`, `sed`, `ls`, and `git status` may run outside Nix.
- While `flake.nix` is untracked, use `nix develop path:. -c <command>`.

View File

@@ -21,8 +21,8 @@
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "^19.2.0",
"react-dom": "19.2.3", "react-dom": "^19.2.0",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",

View File

@@ -7,9 +7,5 @@
"format": "oxfmt --write .", "format": "oxfmt --write .",
"start": "bun run src/agent-test-cli.ts", "start": "bun run src/agent-test-cli.ts",
"typecheck": "bun tsc --noEmit" "typecheck": "bun tsc --noEmit"
},
"dependencies": {
"@freya/agent-protocol": "workspace:*",
"@nym.sh/jrpc": "^0.1.0"
} }
} }

View File

@@ -1,13 +1,3 @@
import type {
AgentClientApi,
AgentEvent,
AgentServerApi,
SendMessageResult,
} from "@freya/agent-protocol"
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
type JsonObject = Record<string, unknown> type JsonObject = Record<string, unknown>
interface AuthUser { interface AuthUser {
@@ -25,6 +15,10 @@ interface AuthSession {
} }
} }
interface QueryResponse {
message: string
}
interface QueryToolDefinition { interface QueryToolDefinition {
name: string name: string
label: string label: string
@@ -66,219 +60,6 @@ class CookieJar {
} }
} }
class AgentWebSocketSession implements AgentClientApi {
private readonly channel: WebSocketJrpcChannel
private readonly client: JsonRpcClient<AgentServerApi>
private readonly server: JsonRpcServer<AgentClientApi>
private conversationId: string | undefined
private responseHadText = false
private constructor(channel: WebSocketJrpcChannel) {
this.channel = channel
this.client = new JsonRpcClient<AgentServerApi>(channel)
this.server = new JsonRpcServer<AgentClientApi>(
{
notify: this.notify.bind(this),
},
channel,
)
}
static async connect(backendUrl: string, cookies: CookieJar): Promise<AgentWebSocketSession> {
const channel = new WebSocketJrpcChannel(agentWebSocketUrl(backendUrl), cookies.header())
const session = new AgentWebSocketSession(channel)
try {
await channel.waitUntilOpen()
void session.server.start().catch((err: unknown) => {
if (!channel.isClosed()) {
console.error(`\nWebSocket JSON-RPC failed: ${formatError(err)}\n`)
}
})
await session.client.call("ping")
} catch (err) {
channel.close()
throw err
}
return session
}
async ask(message: string): Promise<void> {
this.responseHadText = false
const result = await this.sendMessage(message)
if (result.conversationId) {
this.conversationId = result.conversationId
}
if (!this.responseHadText) {
console.log(`\nagent> ${result.message || "(no message)"}`)
}
console.log("")
}
notify(event: AgentEvent): void {
switch (event.type) {
case "conversation_started":
this.conversationId = event.conversationId
break
case "message_created":
this.printMessage(event.text)
break
case "tool_started":
console.log(`\ntool> ${event.toolName} started`)
break
case "tool_finished":
console.log(`tool> ${event.toolName} ${event.ok ? "finished" : "failed"}`)
break
case "message_finished":
break
case "message_failed":
console.log(`\nagent! ${event.error}`)
break
}
}
describeConversation(): string {
return this.conversationId ? `Conversation: ${this.conversationId}` : "No conversation yet."
}
close(): void {
this.channel.close()
}
private async sendMessage(message: string): Promise<SendMessageResult> {
return this.client.call("sendMessage", message)
}
private printMessage(text: string): void {
if (text === "") return
console.log(`\nagent> ${text}`)
this.responseHadText = true
}
}
class WebSocketJrpcChannel implements JrpcChannel {
private readonly ws: WebSocket
private readonly opened: Promise<void>
private closed = false
private openedOnce = false
private queue: JrpcMessage[] = []
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
constructor(url: string, cookieHeader?: string) {
this.ws = new WebSocket(url, createWebSocketOptions(cookieHeader))
this.opened = new Promise((resolve, reject) => {
this.ws.onopen = () => {
this.openedOnce = true
resolve()
}
this.ws.onerror = () => {
if (!this.openedOnce) {
reject(new Error(`Could not connect to ${url}`))
}
}
this.ws.onclose = (event) => {
if (!this.openedOnce) {
reject(new Error(formatWebSocketClose(url, event)))
}
this.close()
}
})
this.ws.onmessage = (event) => {
this.receive(event.data)
}
}
waitUntilOpen(): Promise<void> {
return this.opened
}
isClosed(): boolean {
return this.closed
}
async send(msg: JsonRpcMessage): Promise<void> {
await this.opened
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("JSON-RPC WebSocket channel is closed")
}
this.ws.send(JSON.stringify(msg))
}
async next(): Promise<IteratorResult<JrpcMessage, void>> {
const msg = this.queue.shift()
if (msg) {
return { done: false, value: msg }
}
if (this.closed) {
return { done: true, value: undefined }
}
return new Promise((resolve) => {
this.waiters.push(resolve)
})
}
async return(): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
return { done: true, value: undefined }
}
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
throw error
}
async [Symbol.asyncDispose](): Promise<void> {
await this.return()
}
close(): void {
if (this.closed) return
this.closed = true
for (const resolve of this.waiters.splice(0)) {
resolve({ done: true, value: undefined })
}
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
this.ws.close()
}
}
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
return this
}
private receive(message: unknown): void {
const parsed = parseJrpcMessage(message)
if (!parsed) {
this.ws.close(1003, "Invalid JSON-RPC message")
this.close()
return
}
this.push(parsed)
}
private push(msg: JrpcMessage): void {
if (this.closed) return
const resolve = this.waiters.shift()
if (resolve) {
resolve({ done: false, value: msg })
return
}
this.queue.push(msg)
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
if (wantsHelp()) { if (wantsHelp()) {
printUsage() printUsage()
@@ -330,72 +111,73 @@ async function runChatLoop(
cookies: CookieJar, cookies: CookieJar,
session: AuthSession, session: AuthSession,
): Promise<void> { ): Promise<void> {
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
console.log("Connected to /api/agent/ws")
printHelp() printHelp()
try { for (;;) {
for (;;) { const input = askOptional("you> ")?.trim()
const input = askOptional("you> ")?.trim() if (!input) continue
if (!input) continue
if (input === "/quit" || input === "/exit") { if (input === "/quit" || input === "/exit") {
console.log("Bye.") console.log("Bye.")
return return
} }
if (input === "/help") { if (input === "/help") {
printHelp() printHelp()
continue continue
} }
if (input === "/session") { if (input === "/session") {
console.log(`${session.user.name || session.user.email} (${session.user.id})`) console.log(`${session.user.name || session.user.email} (${session.user.id})`)
continue continue
} }
if (input === "/conversation") { if (input === "/tools") {
console.log(agent.describeConversation()) await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue continue
} }
if (input === "/tools") { if (input.startsWith("/tool ")) {
await runCliCommand(() => listQueryTools(backendUrl, cookies)) await runCliCommand(() => executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)))
continue continue
} }
if (input.startsWith("/tool ")) { if (input.startsWith("/actions ")) {
await runCliCommand(() => await runCliCommand(() =>
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)), listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
) )
continue continue
} }
if (input.startsWith("/actions ")) { if (input.startsWith("/action ")) {
await runCliCommand(() => await runCliCommand(() =>
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)), executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
) )
continue continue
} }
if (input.startsWith("/action ")) { try {
await runCliCommand(() => await askAgent(backendUrl, cookies, input)
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)), } catch (err) {
) console.error(`\n${formatError(err)}\n`)
continue
}
try {
await agent.ask(input)
} catch (err) {
console.error(`\n${formatError(err)}\n`)
}
} }
} finally {
agent.close()
} }
} }
async function askAgent(backendUrl: string, cookies: CookieJar, message: string): Promise<void> {
const data = await requestJson(backendUrl, cookies, "/api/agent", {
method: "POST",
body: { message },
})
if (!isQueryResponse(data)) {
throw new Error("Query returned an unexpected response shape")
}
console.log(`\nagent> ${data.message || "(no message)"}`)
console.log("")
}
async function runCliCommand(command: () => Promise<void>): Promise<void> { async function runCliCommand(command: () => Promise<void>): Promise<void> {
try { try {
await command() await command()
@@ -545,7 +327,7 @@ async function requestJson(
function printIntro(): void { function printIntro(): void {
console.log("FREYA agent test CLI") console.log("FREYA agent test CLI")
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n") console.log("Connect to a backend, sign in, then send test messages to /api/agent.\n")
} }
function printUsage(): void { function printUsage(): void {
@@ -566,7 +348,6 @@ function printHelp(): void {
console.log(" /tool Execute an agent debug tool with JSON params") console.log(" /tool Execute an agent debug tool with JSON params")
console.log(" /actions List source actions: /actions <source-id>") console.log(" /actions List source actions: /actions <source-id>")
console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>") console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>")
console.log(" /conversation Show the current websocket conversation")
console.log(" /session Show the signed-in user") console.log(" /session Show the signed-in user")
console.log(" /help Show commands") console.log(" /help Show commands")
console.log(" /quit Exit\n") console.log(" /quit Exit\n")
@@ -636,33 +417,6 @@ function normalizeBackendUrl(value: string): string {
} }
} }
function agentWebSocketUrl(backendUrl: string): string {
const url = new URL(backendUrl)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = "/api/agent/ws"
url.search = ""
url.hash = ""
return url.toString()
}
function createWebSocketOptions(cookieHeader?: string): Bun.WebSocketOptions | undefined {
if (!cookieHeader) return undefined
return {
headers: {
Cookie: cookieHeader,
},
}
}
function formatWebSocketClose(
url: string,
event: { code: number; reason: string; wasClean: boolean },
): string {
const reason = event.reason ? `: ${event.reason}` : ""
return `Could not connect to ${url} (${event.code}${reason})`
}
function formatPromptLabel(label: string, defaultValue?: string): string { function formatPromptLabel(label: string, defaultValue?: string): string {
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: ` return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
} }
@@ -757,25 +511,6 @@ function splitSetCookieHeader(header: string): string[] {
return parts.filter(Boolean) return parts.filter(Boolean)
} }
function parseJrpcMessage(message: unknown): JrpcMessage | null {
const text = webSocketMessageText(message)
if (!text) return null
try {
const value: unknown = JSON.parse(text)
return isJrpcMessage(value) ? value : null
} catch {
return null
}
}
function webSocketMessageText(message: unknown): string | null {
if (typeof message === "string") return message
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8")
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
return null
}
async function readResponseError(response: Response, path: string): Promise<string> { async function readResponseError(response: Response, path: string): Promise<string> {
const text = await response.text() const text = await response.text()
if (response.status === 404 && path === "/api/agent") { if (response.status === 404 && path === "/api/agent") {
@@ -813,6 +548,11 @@ function isAuthSession(value: unknown): value is AuthSession {
) )
} }
function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false
return typeof value.message === "string"
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse { function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
return value.tools.every(isQueryToolDefinition) return value.tools.every(isQueryToolDefinition)
@@ -845,33 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri
) )
} }
function isJrpcMessage(value: unknown): value is JrpcMessage {
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.id !== "number") {
return false
}
if ("method" in value) {
return (
typeof value.method === "string" &&
(value.params === undefined || Array.isArray(value.params))
)
}
if ("result" in value) {
return true
}
if ("error" in value) {
return isJsonRpcErrorObject(value.error)
}
return false
}
function isJsonRpcErrorObject(value: unknown): boolean {
return isJsonObject(value) && typeof value.code === "number" && typeof value.message === "string"
}
function isJsonObject(value: unknown): value is JsonObject { function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value) return typeof value === "object" && value !== null && !Array.isArray(value)
} }

View File

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

@@ -16,7 +16,6 @@
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.79.1", "@earendil-works/pi-coding-agent": "^0.79.1",
"@freya/agent-protocol": "workspace:*",
"@freya/core": "workspace:*", "@freya/core": "workspace:*",
"@freya/source-caldav": "workspace:*", "@freya/source-caldav": "workspace:*",
"@freya/source-google-calendar": "workspace:*", "@freya/source-google-calendar": "workspace:*",
@@ -26,7 +25,6 @@
"@freya/source-tfl": "workspace:*", "@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*", "@freya/source-weatherkit": "workspace:*",
"@freya/source-web-search": "workspace:*", "@freya/source-web-search": "workspace:*",
"@nym.sh/jrpc": "^0.1.0",
"@openrouter/sdk": "^0.9.11", "@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29", "arktype": "^2.1.29",
"better-auth": "^1", "better-auth": "^1",

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

@@ -3,13 +3,7 @@ import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts" 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,26 +12,19 @@ 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>(
_type: T,
_listener: QueryAgentEventListener<T>,
): () => void {
return () => {}
}
dispose(): void {} dispose(): void {}
} }
@@ -123,27 +110,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

@@ -35,7 +35,6 @@ interface AgentDebugHttpHandlersDeps {
const AgentAskRequestBody = type({ const AgentAskRequestBody = type({
"+": "reject", "+": "reject",
message: "string", message: "string",
"conversationId?": "string",
}) })
export function registerAgentHttpHandlers( export function registerAgentHttpHandlers(
@@ -83,7 +82,6 @@ async function handleAgentAsk(c: Context<Env>) {
const session = await sessionManager.getOrCreate(user.id) const session = await sessionManager.getOrCreate(user.id)
const response = await collectQueryAgentResponse(session.agent, { const response = await collectQueryAgentResponse(session.agent, {
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 { QueryAgentToolbox } from "./query-agent-toolbox.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,61 +9,6 @@ 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 runtimeApiKeyCalls: Array<{ provider: string; apiKey: string }> = []
@@ -109,44 +51,6 @@ 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() {
@@ -167,7 +71,6 @@ 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
}, },
@@ -183,7 +86,7 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
}, },
SessionManager: { SessionManager: {
inMemory(_cwd: string): unknown { inMemory(_cwd: string): unknown {
return new FakeSessionManager() return {}
}, },
}, },
SettingsManager: { SettingsManager: {
@@ -228,6 +131,7 @@ 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({
userId: "user-1",
toolbox: createStubToolbox(), toolbox: createStubToolbox(),
apiKey: "test-api-key", apiKey: "test-api-key",
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
@@ -251,7 +155,7 @@ 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)
@@ -271,228 +175,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,6 +182,7 @@ 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({
userId: "user-1",
toolbox: createStubToolbox(), toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
@@ -536,6 +219,7 @@ 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({
userId: "user-1",
toolbox: createStubToolbox(), toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test", cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test", systemPrompt: "test",
@@ -572,10 +256,8 @@ 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)
} }
@@ -608,79 +290,6 @@ function createStubToolbox(): QueryAgentToolbox {
} }
} }
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> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null
} }

View File

@@ -1,119 +1,73 @@
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 { QueryAgentToolbox } from "./query-agent-toolbox.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 {
userId: string
toolbox: QueryAgentToolbox toolbox: QueryAgentToolbox
apiKey?: string apiKey?: string
cwd?: string cwd?: string
systemPrompt?: string systemPrompt?: string
initialEntries?: ConversationStorageEntry[]
} }
export const PI_MODEL_PROVIDER = "openrouter" const MODEL_PROVIDER = "openrouter"
export const PI_MODEL_ID = "z-ai/glm-4.7-flash" const MODEL_ID = "z-ai/glm-4.7-flash"
export class PiQueryAgent implements QueryAgent { export class PiQueryAgent implements QueryAgent {
private readonly userId: string
private readonly toolbox: QueryAgentToolbox private readonly toolbox: QueryAgentToolbox
private readonly cwd: string private readonly cwd: string
private readonly systemPrompt: string private readonly systemPrompt: string
private readonly apiKey: string | undefined private readonly apiKey: string | undefined
private readonly initialEntries: ConversationStorageEntry[]
private readonly eventListeners = createQueryAgentEventListeners()
private session: PiSession | null = null private session: PiSession | null = null
private pendingSession: Promise<PiSession> | null = null private pendingSession: Promise<PiSession> | null = null
/** private activeRun: symbol | 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 private disposed = false
constructor(config: PiQueryAgentConfig) { constructor(config: PiQueryAgentConfig) {
this.userId = config.userId
this.toolbox = config.toolbox this.toolbox = config.toolbox
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.activeRun) {
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(this.userId)
this.activeUserMessageEntry = input.userMessageEntry ?? null this.activeRun = run
let session: PiSession let session: PiSession
try { try {
session = await this.getOrCreateSession() session = await this.getOrCreateSession()
} catch (err) { } catch (err) {
this.activeConversationId = null this.clearActiveRun(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 +75,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 +88,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 +108,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 +118,7 @@ export class PiQueryAgent implements QueryAgent {
}) })
.finally(() => { .finally(() => {
unsubscribe() unsubscribe()
this.activeConversationId = null this.clearActiveRun(run)
this.activeUserMessageEntry = null
close() close()
}) })
@@ -188,19 +140,12 @@ export class PiQueryAgent implements QueryAgent {
this.session?.dispose() this.session?.dispose()
this.session = null this.session = null
this.pendingSession = null this.pendingSession = null
this.activeConversationId = null this.activeRun = null
this.activeUserMessageEntry = null
this.clearEventListeners()
} }
addEventListener<T extends QueryAgentEvent>( private clearActiveRun(run: symbol): void {
type: T, if (this.activeRun === run) {
listener: QueryAgentEventListener<T>, this.activeRun = null
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
} }
} }
@@ -239,200 +184,38 @@ export class PiQueryAgent implements QueryAgent {
}) })
const authStorage = AuthStorage.inMemory() const authStorage = AuthStorage.inMemory()
if (this.apiKey) { if (this.apiKey) {
authStorage.setRuntimeApiKey(PI_MODEL_PROVIDER, this.apiKey) authStorage.setRuntimeApiKey(MODEL_PROVIDER, 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(MODEL_PROVIDER, MODEL_ID)
if (!model) { if (!model) {
throw new Error(`Pi model not found: ${PI_MODEL_PROVIDER}/${PI_MODEL_ID}`) throw new Error(`Pi model not found: ${MODEL_PROVIDER}/${MODEL_ID}`)
} }
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, toolbox: this.toolbox,
}), }),
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,74 +1,21 @@
export interface QueryAgentAsk { export interface QueryAgentAsk {
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>(
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 +30,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 +45,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,149 +0,0 @@
import type { AgentEvent } from "@freya/agent-protocol"
import { describe, expect, test } from "bun:test"
import type {
QueryAgent,
QueryAgentAsk,
QueryAgentEvent,
QueryAgentEventListener,
QueryAgentStreamEvent,
} from "./query-agent.ts"
import type { AgentResponseStreamItem } from "./streaming.ts"
import { streamAgentResponse } from "./streaming.ts"
class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentStreamEvent[]
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 {
return () => {}
}
dispose(): void {}
}
describe("streamAgentResponse", () => {
test("emits one message event per completed newline", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: "First message\nSec" },
{ type: "text_delta", text: "ond message\nThird message" },
{ type: "done" },
])
const { events, result } = await collectStreamAgentResponse(
streamAgentResponse({
agent,
input: { message: "hello" },
}),
)
expect(result).toEqual({
conversationId: "conversation-1",
message: "First message\nSecond message\nThird message",
})
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: "First message" },
{ type: "message_created", text: "Second message" },
{ type: "message_created", text: "Third message" },
{ type: "message_finished" },
])
})
test("preserves whitespace without emitting empty message events", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: " const value = 1 \n\n return value" },
{ type: "done" },
])
const { events, result } = await collectStreamAgentResponse(
streamAgentResponse({
agent,
input: { message: "hello" },
}),
)
expect(result).toEqual({
conversationId: "conversation-1",
message: " const value = 1 \n\n return value",
})
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: " const value = 1 " },
{ type: "message_created", text: " return value" },
{ type: "message_finished" },
])
})
test("emits tool and failure events", async () => {
const agent = new FakeQueryAgent([
{ type: "conversation", conversationId: "conversation-1" },
{ type: "text_delta", text: "I'll check" },
{ type: "tool_start", toolName: "calendar" },
{ type: "tool_end", toolName: "calendar", ok: false },
{ type: "text_delta", text: "That failed" },
{ type: "error", message: "model unavailable" },
])
const stream = streamAgentResponse({
agent,
input: { message: "hello" },
})
const events: AgentEvent[] = []
await expect(collectStreamAgentResponse(stream, events)).rejects.toThrow("model unavailable")
expect(events).toEqual([
{ type: "conversation_started", conversationId: "conversation-1" },
{ type: "message_created", text: "I'll check" },
{ type: "tool_started", toolName: "calendar" },
{ type: "tool_finished", toolName: "calendar", ok: false },
{ type: "message_created", text: "That failed" },
{ type: "message_failed", error: "model unavailable" },
])
})
})
async function collectStreamAgentResponse(
stream: AsyncIterable<AgentResponseStreamItem>,
events: AgentEvent[] = [],
): Promise<{
events: AgentEvent[]
result: { message: string; conversationId: string }
}> {
let result: { message: string; conversationId: string } | null = null
for await (const item of stream) {
switch (item.type) {
case "event":
events.push(item.event)
break
case "result":
result = item.result
break
}
}
if (!result) {
throw new Error("Expected stream result")
}
return { events, result }
}

View File

@@ -1,125 +0,0 @@
import type { AgentEvent, SendMessageResult } from "@freya/agent-protocol"
import type { QueryAgent, QueryAgentAsk } from "./query-agent.ts"
export type AgentResponseStreamItem =
| { type: "event"; event: AgentEvent }
| { type: "result"; result: SendMessageResult }
export async function* streamAgentResponse({
agent,
input,
}: {
agent: QueryAgent
input: QueryAgentAsk
}): AsyncGenerator<AgentResponseStreamItem, void, void> {
let message = ""
let conversationId: string | null = null
const splitter = new AgentMessageSplitter()
function messageEvent(text: string): AgentResponseStreamItem | null {
if (text.trim() === "") return null
return { type: "event", event: { type: "message_created", text } }
}
function flushPendingMessage(): AgentResponseStreamItem | null {
const text = splitter.flush()
if (text === null) return null
return messageEvent(text)
}
for await (const event of agent.ask(input)) {
switch (event.type) {
case "conversation":
conversationId = event.conversationId
yield { type: "event", event: { type: "conversation_started", conversationId } }
break
case "text_delta":
message += event.text
for (const line of splitter.push(event.text)) {
const item = messageEvent(line)
if (item) yield item
}
break
case "tool_start":
{
const item = flushPendingMessage()
if (item) yield item
}
yield { type: "event", event: { type: "tool_started", toolName: event.toolName } }
break
case "tool_end":
{
const item = flushPendingMessage()
if (item) yield item
}
yield {
type: "event",
event: {
type: "tool_finished",
toolName: event.toolName,
ok: event.ok,
},
}
break
case "error":
{
const item = flushPendingMessage()
if (item) yield item
}
yield { type: "event", event: { type: "message_failed", error: event.message } }
throw new Error(event.message)
case "done":
{
const item = flushPendingMessage()
if (item) yield item
}
const result = createResult(message, conversationId)
yield { type: "event", event: { type: "message_finished" } }
yield { type: "result", result }
return
}
}
const item = flushPendingMessage()
if (item) yield item
const result = createResult(message, conversationId)
yield { type: "event", event: { type: "message_finished" } }
yield { type: "result", result }
}
function createResult(message: string, conversationId: string | null): SendMessageResult {
if (!conversationId) {
throw new Error("Agent response stream ended without a conversation id")
}
return { message, conversationId }
}
class AgentMessageSplitter {
private pending = ""
push(text: string): string[] {
this.pending += text
const lines = this.pending.split(/\r?\n/)
this.pending = lines.pop() ?? ""
return lines
}
flush(): string | null {
if (this.pending === "") return null
const text = this.pending
this.pending = ""
return text
}
}

View File

@@ -1,68 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import { registerAgentWebSocketHandlers } from "./ws.ts"
describe("agent websocket handler", () => {
test("rejects disallowed browser origins before authenticating", async () => {
let sessionChecked = false
const app = new Hono()
registerAgentWebSocketHandlers(app, {
sessionManager: {} as UserSessionManager,
corsMiddleware: async (c, next) => {
const origin = c.req.header("origin")
if (origin && origin !== "https://app.freya.test") {
return c.text("Forbidden", 403)
}
await next()
},
authSessionMiddleware: async (c) => {
sessionChecked = true
return c.json({ error: "Unauthorized" }, 401)
},
})
const res = await app.fetch(
new Request("https://api.freya.test/api/agent/ws", {
headers: {
origin: "https://evil.test",
upgrade: "websocket",
},
}),
)
expect(res.status).toBe(403)
expect(sessionChecked).toBe(false)
})
test("allows requests without an origin header", async () => {
let sessionChecked = false
const app = new Hono()
registerAgentWebSocketHandlers(app, {
sessionManager: {} as UserSessionManager,
corsMiddleware: async (_c, next) => {
await next()
},
authSessionMiddleware: async (c) => {
sessionChecked = true
return c.json({ error: "Unauthorized" }, 401)
},
})
const res = await app.fetch(
new Request("https://api.freya.test/api/agent/ws", {
headers: {
upgrade: "websocket",
},
}),
)
expect(res.status).toBe(401)
expect(sessionChecked).toBe(true)
})
})

View File

@@ -1,287 +0,0 @@
import type { AgentClientApi, AgentServerApi, SendMessageResult } from "@freya/agent-protocol"
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
import type { Hono, MiddlewareHandler } from "hono"
import type { WSContext } from "hono/ws"
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
import { type } from "arktype"
import { upgradeWebSocket, websocket } from "hono/bun"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
import { streamAgentResponse } from "./streaming.ts"
interface AgentWebSocketHandlerDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
corsMiddleware: MiddlewareHandler
}
interface ValidSendMessageInput {
message: string
}
export const agentWebSocket = websocket
const SendMessageInputBody = type({
"+": "reject",
message: "string",
})
export function registerAgentWebSocketHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware, corsMiddleware }: AgentWebSocketHandlerDeps,
): void {
app.get(
"/api/agent/ws",
corsMiddleware,
authSessionMiddleware,
upgradeWebSocket((c) => {
const user = c.get("user")
if (!user) {
throw new Error("Authenticated WebSocket user missing")
}
const channel = new HonoWebSocketJrpcChannel()
const connection = new AgentRpcConnection({
channel,
sessionManager,
userId: user.id,
})
return {
onOpen(_event, ws) {
channel.attach(ws)
void connection.start().catch((err: unknown) => {
console.error("[query] Agent WebSocket JSON-RPC failed:", errorMessage(err))
ws.close(1011, "Agent RPC connection failed")
})
},
onMessage(event) {
channel.receive(event.data)
},
onClose() {
channel.close()
},
}
}),
)
}
class AgentRpcConnection implements AgentServerApi {
private readonly client: JsonRpcClient<AgentClientApi>
private readonly server: JsonRpcServer<AgentServerApi>
private activeMessage: Promise<SendMessageResult> | null = null
private readonly sessionManager: UserSessionManager
private readonly userId: string
constructor({
channel,
sessionManager,
userId,
}: {
channel: JrpcChannel
sessionManager: UserSessionManager
userId: string
}) {
this.sessionManager = sessionManager
this.userId = userId
this.client = new JsonRpcClient<AgentClientApi>(channel)
this.server = new JsonRpcServer<AgentServerApi>(
{
sendMessage: this.sendMessage.bind(this),
ping: this.ping.bind(this),
},
channel,
)
}
start(): Promise<void> {
return this.server.start()
}
async sendMessage(message: string): Promise<SendMessageResult> {
const parsed = SendMessageInputBody({ message })
if (parsed instanceof type.errors) {
throw new Error(parsed.summary)
}
if (this.activeMessage) {
throw new Error("A message is already running")
}
const run = this.runMessage(parsed)
this.activeMessage = run
try {
return await run
} finally {
if (this.activeMessage === run) {
this.activeMessage = null
}
}
}
ping(): "pong" {
return "pong"
}
private async runMessage(input: ValidSendMessageInput): Promise<SendMessageResult> {
const session = await this.sessionManager.getOrCreate(this.userId)
let result: SendMessageResult | null = null
for await (const item of streamAgentResponse({ agent: session.agent, input })) {
switch (item.type) {
case "event":
await this.client.call("notify", item.event)
break
case "result":
result = item.result
break
}
}
if (!result) {
throw new Error("Agent response stream ended without a result")
}
return result
}
}
class HonoWebSocketJrpcChannel implements JrpcChannel {
private closed = false
private queue: JrpcMessage[] = []
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
private ws: WSContext | null = null
attach(ws: WSContext): void {
this.ws = ws
}
async send(msg: JsonRpcMessage): Promise<void> {
if (this.closed || !this.ws) {
throw new Error("JSON-RPC WebSocket channel is closed")
}
this.ws.send(JSON.stringify(msg))
}
receive(message: unknown): void {
const parsed = parseJrpcMessage(message)
if (!parsed) {
this.ws?.close(1003, "Invalid JSON-RPC message")
return
}
this.push(parsed)
}
async next(): Promise<IteratorResult<JrpcMessage, void>> {
const msg = this.queue.shift()
if (msg) {
return { done: false, value: msg }
}
if (this.closed) {
return { done: true, value: undefined }
}
return new Promise((resolve) => {
this.waiters.push(resolve)
})
}
async return(): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
this.ws?.close()
return { done: true, value: undefined }
}
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
throw error
}
async [Symbol.asyncDispose](): Promise<void> {
await this.return()
}
close(): void {
if (this.closed) return
this.closed = true
for (const resolve of this.waiters.splice(0)) {
resolve({ done: true, value: undefined })
}
}
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
return this
}
private push(msg: JrpcMessage): void {
if (this.closed) return
const resolve = this.waiters.shift()
if (resolve) {
resolve({ done: false, value: msg })
return
}
this.queue.push(msg)
}
}
function parseJrpcMessage(message: unknown): JrpcMessage | null {
const text = webSocketMessageText(message)
if (text === null) return null
try {
const value: unknown = JSON.parse(text)
return isJrpcMessage(value) ? value : null
} catch {
return null
}
}
function webSocketMessageText(message: unknown): string | null {
if (typeof message === "string") return message
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
if (ArrayBuffer.isView(message)) {
return Buffer.from(message.buffer, message.byteOffset, message.byteLength).toString("utf8")
}
return null
}
function isJrpcMessage(value: unknown): value is JrpcMessage {
if (typeof value !== "object" || value === null) return false
if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") return false
if ("method" in value) {
return "id" in value && typeof value.id === "number" && typeof value.method === "string"
}
if ("result" in value) {
return "id" in value && typeof value.id === "number"
}
if ("error" in value) {
return (
"id" in value &&
typeof value.id === "number" &&
typeof value.error === "object" &&
value.error !== null
)
}
return false
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View File

@@ -53,6 +53,16 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
} }
} }
/**
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
*/
export function createGetSessionFromHeaders(auth: Auth) {
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
const session = await auth.api.getSession({ headers })
return session
}
}
/** /**
* Dev/test middleware that injects a fake user and session. * Dev/test middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401. * Pass userId to simulate an authenticated request, or omit to get 401.

View File

@@ -1,110 +0,0 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerConversationsHttpHandlers } from "./http.ts"
const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
const conversationRowsByUser = new Map<string, ConversationRow[]>()
mock.module("./storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async listConversations(): Promise<ConversationRow[]> {
return conversationRowsByUser.get(userId) ?? []
},
}),
}))
const fakeDb = {} as Database
function buildTestApp(userId?: string) {
const app = new Hono()
registerConversationsHttpHandlers(app, {
db: fakeDb,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return app
}
function createConversationRow(
id: string,
createdAt: string,
updatedAt: string,
userId = MockUserId,
): ConversationRow {
return {
id,
userId,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
}
}
describe("GET /api/conversations", () => {
beforeEach(() => {
conversationRowsByUser.clear()
})
test("returns 401 without auth", async () => {
const app = buildTestApp()
const res = await app.request("/api/conversations")
expect(res.status).toBe(401)
})
test("returns conversation summaries for the authenticated user", async () => {
conversationRowsByUser.set(MockUserId, [
createConversationRow(
"conversation-newer",
"2026-06-16T10:00:00.000Z",
"2026-06-17T09:30:00.000Z",
),
createConversationRow(
"conversation-older",
"2026-06-15T10:00:00.000Z",
"2026-06-16T09:30:00.000Z",
),
])
const app = buildTestApp("user-1")
const res = await app.request("/api/conversations")
expect(res.status).toBe(200)
const body = (await res.json()) as {
conversations: Array<{ id: string; createdAt: string; updatedAt: string }>
}
expect(body).toEqual({
conversations: [
{
id: "conversation-newer",
createdAt: "2026-06-16T10:00:00.000Z",
updatedAt: "2026-06-17T09:30:00.000Z",
},
{
id: "conversation-older",
createdAt: "2026-06-15T10:00:00.000Z",
updatedAt: "2026-06-16T09:30:00.000Z",
},
],
})
})
test("returns an empty list when no conversations exist", async () => {
const app = buildTestApp("user-1")
const res = await app.request("/api/conversations")
expect(res.status).toBe(200)
const body = (await res.json()) as {
conversations: Array<{ id: string; createdAt: string; updatedAt: string }>
}
expect(body).toEqual({
conversations: [],
})
})
})

View File

@@ -1,44 +0,0 @@
import type { Context, Hono } from "hono"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts"
import { conversations } from "./storage.ts"
type Env = {
Variables: {
db: Database
}
}
interface ConversationsHttpHandlersDeps {
db: Database
authSessionMiddleware: AuthSessionMiddleware
}
export function registerConversationsHttpHandlers(
app: Hono,
{ db, authSessionMiddleware }: ConversationsHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("db", db)
await next()
})
app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations)
}
async function handleListConversations(c: Context<Env>) {
const user = c.get("user")!
const db = c.get("db")
return c.json({
conversations: (await conversations(db, user.id).listConversations()).map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
})
}

View File

@@ -1,381 +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 listConversations(): Promise<ConversationRow[]> {
return db
.select()
.from(conversationsTable)
.where(eq(conversationsTable.userId, userId))
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
},
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

@@ -1,11 +1,9 @@
import { Hono } from "hono" import { Hono } from "hono"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { createMiddleware } from "hono/factory"
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 { agentWebSocket, registerAgentWebSocketHandlers } from "./agent/ws.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"
@@ -83,15 +81,6 @@ function main() {
return allowedOrigins.includes(origin) ? origin : undefined return allowedOrigins.includes(origin) ? origin : undefined
} }
const agentWebSocketCorsMiddleware = createMiddleware(async (c, next) => {
const origin = c.req.header("origin")
if (origin && resolveOrigin(origin) === undefined) {
return c.text("Forbidden", 403)
}
await next()
})
app.use( app.use(
"/api/auth/*", "/api/auth/*",
cors({ cors({
@@ -138,12 +127,6 @@ function main() {
} }
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db }) registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
registerAgentWebSocketHandlers(app, {
sessionManager,
authSessionMiddleware,
corsMiddleware: agentWebSocketCorsMiddleware,
})
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
sessionManager.dispose() sessionManager.dispose()
await closeDb() await closeDb()
@@ -159,5 +142,4 @@ export default {
port: 3000, port: 3000,
hostname: "0.0.0.0", hostname: "0.0.0.0",
fetch: app.fetch, fetch: app.fetch,
websocket: agentWebSocket,
} }

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,7 +8,6 @@ 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,
@@ -363,7 +362,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 +373,7 @@ export class UserSessionManager {
} }
if (promises.length === 0) { if (promises.length === 0) {
return this.initializedSession(userId, [], agentConfig) return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
} }
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
@@ -399,29 +397,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, this.queryAgentConfig)
}
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", [
@@ -108,32 +67,6 @@ describe("UserSession", () => {
expect(disposeSpy).toHaveBeenCalled() 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

@@ -10,30 +10,22 @@ import type { QueryAgentToolbox } from "../agent/query-agent-toolbox.ts"
import type { QueryAgent } from "../agent/query-agent.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 { import { PiQueryAgent } from "../agent/pi-query-agent.ts"
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" import { UserSessionQueryAgentToolbox } from "../agent/user-session-query-agent-toolbox.ts"
export interface UserSessionAgentConfig { export interface UserSessionAgentConfig {
apiKey?: string apiKey?: string
cwd?: string cwd?: string
systemPrompt?: 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 readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>() private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null private readonly enhancer: FeedEnhancer | null
private 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
@@ -49,7 +41,6 @@ export class UserSession {
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)
@@ -63,43 +54,17 @@ export class UserSession {
} }
this.toolbox = new UserSessionQueryAgentToolbox(this) this.toolbox = new UserSessionQueryAgentToolbox(this)
if (!agentConfig?.conversationStorage) { this.agent = new PiQueryAgent({
this.queryAgent = new PiQueryAgent({ userId: this.userId,
toolbox: this.toolbox, toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey, apiKey: agentConfig?.apiKey,
cwd: this.agentConfig?.cwd, cwd: agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt, systemPrompt: 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 +201,7 @@ export class UserSession {
} }
destroy(): void { destroy(): void {
this.queryAgent?.dispose() this.agent.dispose()
this.queryAgent = null
this.unsubscribe?.() this.unsubscribe?.()
this.unsubscribe = null this.unsubscribe = null
this.engine.stop() this.engine.stop()
@@ -246,38 +210,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

@@ -1,12 +1,13 @@
{ {
"expo": { "expo": {
"name": "Freya", "name": "Freya",
"slug": "freya", "slug": "freya-client",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "freya", "scheme": "freya",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": { "ios": {
"infoPlist": { "infoPlist": {
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
@@ -23,6 +24,7 @@
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "sh.nym.freya" "package": "sh.nym.freya"
}, },
@@ -52,82 +54,55 @@
{ {
"fontFamily": "Inter", "fontFamily": "Inter",
"fontDefinitions": [ "fontDefinitions": [
{ { "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
"path": "./assets/fonts/Inter_100Thin.ttf",
"weight": 100
},
{ {
"path": "./assets/fonts/Inter_100Thin_Italic.ttf", "path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100, "weight": 100,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
"path": "./assets/fonts/Inter_200ExtraLight.ttf",
"weight": 200
},
{ {
"path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"weight": 200, "weight": 200,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
"path": "./assets/fonts/Inter_300Light.ttf",
"weight": 300
},
{ {
"path": "./assets/fonts/Inter_300Light_Italic.ttf", "path": "./assets/fonts/Inter_300Light_Italic.ttf",
"weight": 300, "weight": 300,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
"path": "./assets/fonts/Inter_400Regular.ttf",
"weight": 400
},
{ {
"path": "./assets/fonts/Inter_400Regular_Italic.ttf", "path": "./assets/fonts/Inter_400Regular_Italic.ttf",
"weight": 400, "weight": 400,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
"path": "./assets/fonts/Inter_500Medium.ttf",
"weight": 500
},
{ {
"path": "./assets/fonts/Inter_500Medium_Italic.ttf", "path": "./assets/fonts/Inter_500Medium_Italic.ttf",
"weight": 500, "weight": 500,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
"path": "./assets/fonts/Inter_600SemiBold.ttf",
"weight": 600
},
{ {
"path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf",
"weight": 600, "weight": 600,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
"path": "./assets/fonts/Inter_700Bold.ttf",
"weight": 700
},
{ {
"path": "./assets/fonts/Inter_700Bold_Italic.ttf", "path": "./assets/fonts/Inter_700Bold_Italic.ttf",
"weight": 700, "weight": 700,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
"path": "./assets/fonts/Inter_800ExtraBold.ttf",
"weight": 800
},
{ {
"path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"weight": 800, "weight": 800,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
"path": "./assets/fonts/Inter_900Black.ttf",
"weight": 900
},
{ {
"path": "./assets/fonts/Inter_900Black_Italic.ttf", "path": "./assets/fonts/Inter_900Black_Italic.ttf",
"weight": 900, "weight": 900,
@@ -138,73 +113,49 @@
{ {
"fontFamily": "Source Serif 4", "fontFamily": "Source Serif 4",
"fontDefinitions": [ "fontDefinitions": [
{ { "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
"path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"weight": 200
},
{ {
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200, "weight": 200,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
"path": "./assets/fonts/SourceSerif4_300Light.ttf",
"weight": 300
},
{ {
"path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"weight": 300, "weight": 300,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
"path": "./assets/fonts/SourceSerif4_400Regular.ttf",
"weight": 400
},
{ {
"path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"weight": 400, "weight": 400,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
"path": "./assets/fonts/SourceSerif4_500Medium.ttf",
"weight": 500
},
{ {
"path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"weight": 500, "weight": 500,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
"path": "./assets/fonts/SourceSerif4_600SemiBold.ttf",
"weight": 600
},
{ {
"path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"weight": 600, "weight": 600,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
"path": "./assets/fonts/SourceSerif4_700Bold.ttf",
"weight": 700
},
{ {
"path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"weight": 700, "weight": 700,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
"path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"weight": 800
},
{ {
"path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"weight": 800, "weight": 800,
"style": "italic" "style": "italic"
}, },
{ { "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
"path": "./assets/fonts/SourceSerif4_900Black.ttf",
"weight": 900
},
{ {
"path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf",
"weight": 900, "weight": 900,
@@ -253,9 +204,7 @@
] ]
} }
} }
], ]
"expo-web-browser",
"expo-image"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
@@ -264,7 +213,7 @@
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
"projectId": "c54ea4e5-27da-4066-b081-db8005ecf70a" "projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
} }
} }
} }

View File

@@ -10,8 +10,8 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive", "build:ios": "eas build --profile development --platform ios --non-interactive",
"build:ios-simulator": "bunx eas-cli build --profile development-simulator --platform ios --non-interactive", "build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts" "debugger": "bun run scripts/open-debugger.ts"
}, },
"dependencies": { "dependencies": {
@@ -19,38 +19,42 @@
"@expo-google-fonts/source-serif-4": "^0.4.1", "@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0", "@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"expo": "^56.0.0", "expo": "~54.0.33",
"expo-constants": "~56.0.18", "expo-constants": "~18.0.13",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~6.0.20",
"expo-font": "~56.0.7", "expo-font": "~14.0.11",
"expo-haptics": "~56.0.3", "expo-haptics": "~15.0.8",
"expo-image": "~56.0.11", "expo-image": "~3.0.11",
"expo-linking": "~56.0.14", "expo-linking": "~8.0.11",
"expo-location": "~56.0.18", "expo-location": "~19.0.8",
"expo-router": "~56.2.11", "expo-router": "~6.0.23",
"expo-splash-screen": "~56.0.10", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~3.0.9",
"expo-symbols": "~56.0.6", "expo-symbols": "~1.0.8",
"expo-system-ui": "~56.0.5", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~56.0.5", "expo-web-browser": "~15.0.10",
"react": "19.2.3", "react": "19.1.0",
"react-dom": "19.2.3", "react-dom": "19.1.0",
"react-native": "0.85.3", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "4.25.2", "react-native-screens": "~4.16.0",
"react-native-svg": "15.15.4", "react-native-svg": "15.12.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3", "react-native-worklets": "0.5.1",
"twrnc": "^4.16.0", "twrnc": "^4.16.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.10", "@types/react": "~19.1.0",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~56.0.4", "eslint-config-expo": "~10.0.0",
"typescript": "~6.0.3" "typescript": "^6"
} }
} }

View File

@@ -8,16 +8,14 @@ import type { ServerWebSocket } from "bun"
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10) const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0" const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0"
const METRO_HOST = process.env.METRO_HOST || "localhost"
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10) const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://${METRO_HOST}:${METRO_PORT}` const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
const METRO_WS_BASE = `ws://${METRO_HOST}:${METRO_PORT}`
function forwardHeaders(headers: Headers): Headers { function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers) const result = new Headers(headers)
result.delete("origin") result.delete("origin")
result.delete("referer") result.delete("referer")
result.set("host", `${METRO_HOST}:${METRO_PORT}`) result.set("host", `127.0.0.1:${METRO_PORT}`)
return result return result
} }
@@ -42,7 +40,7 @@ Bun.serve<WsData>({
// WebSocket upgrade — bridge to Metro's ws endpoint // WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const wsUrl = `${METRO_WS_BASE}${url.pathname}${url.search}` const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
const upstream = new WebSocket(wsUrl) const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client // Wait for upstream to connect before upgrading the client
@@ -67,12 +65,12 @@ Bun.serve<WsData>({
// HTTP proxy // HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}` const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const body = req.body ? await req.arrayBuffer() : undefined const body = req.body ? await req.arrayBuffer() : undefined
const res = await fetchUpstream(upstream, req.method, forwardHeaders(req.headers), body) const res = await fetch(upstream, {
if (res == null) { method: req.method,
return new Response(`Metro is not reachable on ${METRO_HOST}. Restart the Expo dev server.`, { headers: forwardHeaders(req.headers),
status: 502, body,
}) redirect: "manual",
} })
return new Response(res.body, { return new Response(res.body, {
status: res.status, status: res.status,
@@ -123,7 +121,9 @@ async function printDebuggerUrl() {
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend) const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return if (!target) return
const wsPath = getProxyWebSocketPath(target.webSocketDebuggerUrl) const wsPath = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log( console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`, `\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
@@ -131,28 +131,9 @@ async function printDebuggerUrl() {
} }
console.log( console.log(
`[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to ${METRO_HOST}:${METRO_PORT}`, `[proxy] listening on ${PROXY_HOST}:${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`,
) )
async function fetchUpstream(
upstream: string,
method: string,
headers: Headers,
body: ArrayBuffer | undefined,
) {
try {
return await fetch(upstream, {
method,
headers,
body,
redirect: "manual",
})
} catch {
console.error(`[proxy] ${method} ${upstream} failed; Metro is not reachable`)
return null
}
}
function isDebugTarget(value: unknown): value is DebugTarget { function isDebugTarget(value: unknown): value is DebugTarget {
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
@@ -168,11 +149,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean" return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
} }
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null
} }

View File

@@ -4,6 +4,7 @@
import { $ } from "bun" import { $ } from "bun"
const PROXY_PORT = process.env.PROXY_PORT || "8080" const PROXY_PORT = process.env.PROXY_PORT || "8080"
const METRO_PORT = process.env.METRO_PORT || "8081"
const tsIp = (await $`tailscale ip -4`.text()).trim() const tsIp = (await $`tailscale ip -4`.text()).trim()
const base = `http://${tsIp}:${PROXY_PORT}` const base = `http://${tsIp}:${PROXY_PORT}`
@@ -36,7 +37,9 @@ if (!target) {
process.exit(1) process.exit(1)
} }
const wsUrl = getProxyWebSocketPath(target.webSocketDebuggerUrl) const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true` const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
@@ -68,11 +71,6 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean" return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
} }
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null
} }

View File

@@ -1,47 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PROXY_PORT=${PROXY_PORT:-8080} PROXY_PORT=8080
METRO_HOST=${METRO_HOST:-localhost} METRO_PORT=8081
METRO_PORT=${METRO_PORT:-8081}
TS_IP=$(tailscale ip -4) TS_IP=$(tailscale ip -4)
port_is_open() { # Start a reverse proxy so Metro sees all requests as loopback.
(: >"/dev/tcp/$1/$2") >/dev/null 2>&1 # This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
} # accessible through the Tailscale IP.
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
ensure_port_available() {
local port=$1
local name=$2
if port_is_open localhost "$port"; then
echo "$name port $port is already in use." >&2
echo "Stop the existing process or set ${name}_PORT to another value." >&2
exit 1
fi
}
wait_for_metro() {
for _ in {1..120}; do
if port_is_open "$METRO_HOST" "$METRO_PORT"; then
return 0
fi
sleep 0.5
done
echo "Metro did not start on ${METRO_HOST}:${METRO_PORT}." >&2
return 1
}
ensure_port_available "$PROXY_PORT" PROXY
ensure_port_available "$METRO_PORT" METRO
# Start the proxy only after Metro is listening. Otherwise an iOS client can hit
# the proxy during Expo startup and get a misleading upstream connection error.
(
wait_for_metro
exec env PROXY_PORT=$PROXY_PORT METRO_HOST=$METRO_HOST METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts
) &
PROXY_PID=$! PROXY_PID=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT trap "kill $PROXY_PID 2>/dev/null" EXIT

View File

@@ -1,5 +1,5 @@
import Feather from "@expo/vector-icons/Feather" import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, type StyleProp, View, type ViewStyle } from "react-native" import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text" import { SansSerifText } from "./sans-serif-text"
@@ -14,10 +14,9 @@ function ButtonIcon({ name }: ButtonIconProps) {
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} /> return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
} }
type ButtonProps = Omit<PressableProps, "children" | "style"> & { type ButtonProps = Omit<PressableProps, "children"> & {
label: string label: string
leadingIcon?: React.ReactNode leadingIcon?: React.ReactNode
style?: StyleProp<ViewStyle>
trailingIcon?: React.ReactNode trailingIcon?: React.ReactNode
} }

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.png" },
{ 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.png" },
] ]
} }
@@ -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

@@ -16,9 +16,9 @@
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"motion": "^12.35.0", "motion": "^12.35.0",
"react": "19.2.3", "react": "^19.2.4",
"react-aria-components": "^1.16.0", "react-aria-components": "^1.16.0",
"react-dom": "19.2.3", "react-dom": "^19.2.4",
"react-router": "7.12.0", "react-router": "7.12.0",
"resend": "^6.9.3", "resend": "^6.9.3",
"streamdown": "^2.4.0" "streamdown": "^2.4.0"

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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

1848
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[install]
linker = "hoisted"

27
flake.lock generated
View File

@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

282
flake.nix
View File

@@ -1,282 +0,0 @@
{
description = "FREYA development shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ nixpkgs, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
lib = nixpkgs.lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = forEachSystem (system: import nixpkgs { inherit system; });
# App outputs are for long-running local tools and dev servers.
appScripts = {
expo = "expo";
drizzle-studio = "drizzle-studio";
freya-backend = "freya-backend";
admin-dashboard = "admin-dashboard";
agent-test-cli = "agent-test-cli";
};
# Check outputs are the CI-like validation commands run by `nix flake check`.
checkCommands = {
format-check = "bun run format:check";
lint = "bun run lint";
test = "bun run test";
};
# Dev-shell conveniences mirror the common app/check commands.
shellScripts = appScripts // {
freya-test = "test";
lint = "lint";
format-check = "format:check";
};
# node_modules is content-addressed. If bun.lock or package manifests
# change, Nix will report the new hash to put here.
nodeModulesHashes = {
x86_64-linux = "sha256-8uhlaQAFfCgGdUlrz8sqhtIkC/WfdasbTCi3p/NkU/w=";
};
checkSystems = lib.attrNames nodeModulesHashes;
# Dependency derivations only need the lockfile and workspace manifests,
# so source-only edits do not force Bun to reinstall.
dependencySource = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.fileFilter (
file: file.name == "bun.lock" || file.name == "package.json" || file.name == "bunfig.toml"
) ./.;
};
# Checks run against a clean source tree, even when using `path:.`.
# Without this filter, local node_modules can sneak into the Nix sandbox.
projectSource = builtins.path {
name = "freya-source";
path = ./.;
filter =
path: type:
let
name = builtins.baseNameOf path;
in
!(type == "directory" && (name == ".git" || name == "node_modules")) && name != "result";
};
mkBunScriptCommands =
pkgs: scripts:
let
mkBunScript =
name: script:
pkgs.writeShellApplication {
inherit name;
runtimeInputs = with pkgs; [
bun
git
];
text = ''
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root"
exec bun run ${lib.escapeShellArg script} "$@"
'';
};
in
lib.mapAttrs mkBunScript scripts;
mkBunApps =
commands:
lib.mapAttrs (name: command: {
type = "app";
program = "${command}/bin/${name}";
}) commands;
mkBunNodeModules =
system: pkgs:
pkgs.stdenvNoCC.mkDerivation {
pname = "freya-node-modules";
version = "1";
__structuredAttrs = true;
src = dependencySource;
nativeBuildInputs = with pkgs; [
bun
cacert
nodejs
];
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
GIT_SSL_CAINFO = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = nodeModulesHashes.${system};
# `patchShebangs` embeds Nix store interpreters in package bins. The
# check derivations also depend on bun/node, so this dependency blob
# can safely drop those references after its hash is verified.
unsafeDiscardReferences.out = true;
dontConfigure = true;
# Workspace package links are completed inside each check's source tree,
# so they are intentionally dangling in this dependency-only output.
dontFixup = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Keep the real workspace manifest for `--frozen-lockfile`, but
# filter out frontend workspaces that do not participate in checks.
# `--force` matters in the Nix sandbox: without it, Bun can accept
# manifest-only cached packages and leave tool binaries missing.
bun install \
--force \
--frozen-lockfile \
--ignore-scripts \
--backend copyfile \
--filter freya \
--filter '@freya/*' \
--filter '@freya/backend' \
--no-progress
patchShebangs node_modules
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
# Keep the root install in the store; checks symlink this directly.
cp -a node_modules "$out/node_modules"
# Bun also creates per-workspace node_modules directories. These are
# mostly relative symlinks, so checks copy the symlink entries into
# their writable source tree instead of symlinking the directory.
find apps packages -mindepth 2 -maxdepth 2 -type d -name node_modules -print |
while IFS= read -r node_modules_dir; do
mkdir -p "$out/$(dirname "$node_modules_dir")"
cp -a "$node_modules_dir" "$out/$node_modules_dir"
done
runHook postInstall
'';
};
mkBunCheck =
pkgs: nodeModules: name: command:
pkgs.stdenvNoCC.mkDerivation {
pname = "freya-${name}";
version = "1";
src = projectSource;
nativeBuildInputs = with pkgs; [
bun
nodejs
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Root dependencies are read-only and shared across checks.
ln -s "${nodeModules}/node_modules" node_modules
# Workspace node_modules contain relative symlinks back to packages/
# and apps/, so copy just those symlink entries into this source tree.
for node_modules_dir in "${nodeModules}"/apps/*/node_modules "${nodeModules}"/packages/*/node_modules; do
if [ -d "$node_modules_dir" ]; then
relative_path="''${node_modules_dir#"${nodeModules}/"}"
mkdir -p "$relative_path"
cp -a "$node_modules_dir/." "$relative_path/"
fi
done
${command}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
touch "$out/${name}"
runHook postInstall
'';
};
in
{
apps = forEachSystem (
system:
let
pkgs = pkgsFor.${system};
in
mkBunApps (mkBunScriptCommands pkgs appScripts)
);
checks = lib.genAttrs checkSystems (
system:
let
pkgs = pkgsFor.${system};
nodeModules = mkBunNodeModules system pkgs;
in
lib.mapAttrs (mkBunCheck pkgs nodeModules) checkCommands
);
devShells = forEachSystem (
system:
let
pkgs = pkgsFor.${system};
bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts);
commonPackages = with pkgs; [
bun
eas-cli
git
gh
gnumake
nixfmt
nodejs
openssl
pkg-config
postgresql
python3
watchman
];
linuxPackages = with pkgs; [
gcc
inotify-tools
tailscale
];
in
{
default = pkgs.mkShell {
packages =
commonPackages ++ bunScriptCommands ++ pkgs.lib.optionals pkgs.stdenv.isLinux linuxPackages;
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
shellHook = ''
export PATH="$PWD/node_modules/.bin:$PATH"
'';
};
}
);
formatter = forEachSystem (system: pkgsFor.${system}.nixfmt);
};
}

View File

@@ -10,7 +10,6 @@
"expo": "cd apps/freya-client && bun run start", "expo": "cd apps/freya-client && bun run start",
"drizzle-studio": "TS_IP=$(tailscale ip -4); echo \"Drizzle Studio: https://local.drizzle.studio/?host=${TS_IP}&port=4983\"; cd apps/freya-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983", "drizzle-studio": "TS_IP=$(tailscale ip -4); echo \"Drizzle Studio: https://local.drizzle.studio/?host=${TS_IP}&port=4983\"; cd apps/freya-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983",
"freya-backend": "TS_IP=$(tailscale ip -4); echo \"Freya Backend: http://${TS_IP}:3000\"; echo \"\"; echo \"------------------ Bun Debugger ------------------\"; echo \"https://debug.bun.sh/#${TS_IP}:6499\"; echo \"------------------ Bun Debugger ------------------\"; echo \"\"; cd apps/freya-backend && bun run dev", "freya-backend": "TS_IP=$(tailscale ip -4); echo \"Freya Backend: http://${TS_IP}:3000\"; echo \"\"; echo \"------------------ Bun Debugger ------------------\"; echo \"https://debug.bun.sh/#${TS_IP}:6499\"; echo \"------------------ Bun Debugger ------------------\"; echo \"\"; cd apps/freya-backend && bun run dev",
"client": "bun run --elide-lines=0 --filter freya-client start",
"admin-dashboard": "TS_IP=$(tailscale ip -4); echo \"Admin Dashboard: http://${TS_IP}:5174\"; cd apps/admin-dashboard && bun run dev --host 0.0.0.0", "admin-dashboard": "TS_IP=$(tailscale ip -4); echo \"Admin Dashboard: http://${TS_IP}:5174\"; cd apps/admin-dashboard && bun run dev --host 0.0.0.0",
"agent-test-cli": "cd apps/agent-test-cli && bun run start", "agent-test-cli": "cd apps/agent-test-cli && bun run start",
"test": "bun run --filter '*' test", "test": "bun run --filter '*' test",

View File

@@ -1,10 +0,0 @@
{
"name": "@freya/agent-protocol",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ./src"
}
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AgentEvent, AgentServerApi } from "./index"
describe("agent protocol", () => {
test("defines server methods and agent events", () => {
const server: AgentServerApi = {
async sendMessage(message) {
return { message, conversationId: "conversation-1" }
},
ping() {
return "pong"
},
}
const event: AgentEvent = { type: "message_finished" }
expect(server.ping()).toBe("pong")
expect(event.type).toBe("message_finished")
})
})

View File

@@ -1,21 +0,0 @@
export interface SendMessageResult {
message: string
conversationId: string
}
export type AgentEvent =
| { type: "conversation_started"; conversationId: string }
| { type: "message_created"; text: string }
| { type: "tool_started"; toolName: string }
| { type: "tool_finished"; toolName: string; ok: boolean }
| { type: "message_finished" }
| { type: "message_failed"; error: string }
export interface AgentServerApi {
sendMessage(message: string): Promise<SendMessageResult>
ping(): "pong"
}
export interface AgentClientApi {
notify(event: AgentEvent): void
}

View File

@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index" import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
@@ -145,16 +145,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
} }
} }
async function waitForCondition(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error("Timed out waiting for condition")
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
// ============================================================================= // =============================================================================
// TESTS // TESTS
// ============================================================================= // =============================================================================
@@ -817,35 +807,28 @@ describe("FeedEngine", () => {
}) })
test("TTL resets after reactive update", async () => { test("TTL resets after reactive update", async () => {
let now = 1_000
const nowSpy = spyOn(Date, "now").mockImplementation(() => now)
const location = createLocationSource() const location = createLocationSource()
const weather = createWeatherSource() const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather) const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
try { engine.start()
engine.start()
// Initial reactive update // Initial reactive update
location.simulateUpdate({ lat: 51.5, lng: -0.1 }) location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
expect(engine.lastFeed()).not.toBeNull() expect(engine.lastFeed()).not.toBeNull()
// Move past the original TTL, then trigger another update to reset it. // Wait 70ms (total 120ms from first update, past original TTL)
now += 120 // but trigger another update at 50ms to reset TTL
location.simulateUpdate({ lat: 52.0, lng: -0.2 }) location.simulateUpdate({ lat: 52.0, lng: -0.2 })
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
// Should still be cached because TTL was reset by second update. // Should still be cached because TTL was reset by second update
expect(engine.lastFeed()).not.toBeNull() expect(engine.lastFeed()).not.toBeNull()
engine.stop() engine.stop()
} finally {
engine.stop()
nowSpy.mockRestore()
}
}) })
test("cacheTtlMs is configurable", async () => { test("cacheTtlMs is configurable", async () => {
@@ -886,21 +869,17 @@ describe("FeedEngine", () => {
}, },
} }
const engine = new FeedEngine({ cacheTtlMs: 20 }).register(source) const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
await engine.refresh() engine.start()
expect(fetchCount).toBe(1) // Wait for two TTL intervals to elapse
await new Promise((resolve) => setTimeout(resolve, 120))
try { // Should have auto-refreshed at least twice
engine.start() expect(fetchCount).toBeGreaterThanOrEqual(2)
expect(engine.lastFeed()).not.toBeNull()
await waitForCondition(() => fetchCount >= 2) engine.stop()
expect(fetchCount).toBeGreaterThanOrEqual(2)
expect(engine.lastFeed()).not.toBeNull()
} finally {
engine.stop()
}
}) })
test("stop cancels periodic refresh", async () => { test("stop cancels periodic refresh", async () => {
@@ -956,25 +935,28 @@ describe("FeedEngine", () => {
}, },
} }
const engine = new FeedEngine({ cacheTtlMs: 10_000 }) const engine = new FeedEngine({ cacheTtlMs: 100 })
.register(location) .register(location)
.register(countingWeather) .register(countingWeather)
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
try { engine.start()
engine.start()
const countBeforeUpdate = fetchCount // At 40ms, push a reactive update — this resets the timer
location.simulateUpdate({ lat: 51.5, lng: -0.1 }) await new Promise((resolve) => setTimeout(resolve, 40))
await waitForCondition(() => fetchCount > countBeforeUpdate && engine.lastFeed() !== null) const countBeforeUpdate = fetchCount
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 20))
// Reactive updates refresh the cache and reset the pending periodic timer. // Reactive update triggered a fetch
expect(fetchCount).toBeGreaterThan(countBeforeUpdate) expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
expect(clearTimeoutSpy).toHaveBeenCalled() const countAfterUpdate = fetchCount
} finally {
engine.stop() // At 100ms from start (60ms after reactive update), the original
clearTimeoutSpy.mockRestore() // timer would have fired, but it was reset. No extra fetch yet.
} await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchCount).toBe(countAfterUpdate)
engine.stop()
}) })
}) })