Compare commits

..

15 Commits

Author SHA1 Message Date
e6af1b7851 refactor: move conversation types to core (#149) 2026-06-18 20:47:36 +01:00
769fd5c77d feat: add conversation entries API (#148) 2026-06-18 17:19:47 +01:00
6cc0f7669a fix: upgrade client to expo 56 (#147)
Upgrade the React Native client through Expo SDK 56, align workspace React versions, switch Bun installs to the hoisted linker for Expo compatibility, and fix the Metro proxy to handle localhost/IPv6 loopback after the SDK upgrade.
2026-06-18 16:25:54 +01:00
63e71fb828 feat: add eas-cli to flake (#146) 2026-06-18 14:50:58 +01:00
e9f97d6f02 chore: add GitHub CLI to dev shell (#145) 2026-06-18 13:24:43 +01:00
e52e057548 ci: add nix dev tooling (#144) 2026-06-18 13:12:52 +01:00
d3d9def260 feat: use websocket in agent cli (#143) 2026-06-17 23:35:00 +01:00
227f196d5e feat: add agent websocket endpoint (#142) 2026-06-17 23:19:45 +01:00
e1c58cdf28 feat: add conversations endpoint (#141) 2026-06-17 23:01:08 +01:00
e11051b04b feat: add conversation storage (#140) 2026-06-16 20:16:03 +01:00
95f6c99f19 refactor: split query agent toolbox (#139) 2026-06-15 20:58:07 +01:00
bdf0392a55 fix: update waitlist domain (#138) 2026-06-15 16:51:24 +01:00
fc82af3f55 fix: wrong social media preview aspect ratio (#137) 2026-06-15 14:20:10 +01:00
9dc25dbc94 feat: use new social media preview (#136) 2026-06-15 14:05:52 +01:00
2b02c1a9d0 feat: change social media preview (#135) 2026-06-15 13:46:46 +01:00
62 changed files with 5938 additions and 2133 deletions

View File

@@ -39,4 +39,13 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- 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",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^4.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",

View File

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

View File

@@ -1,3 +1,13 @@
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>
interface AuthUser {
@@ -15,10 +25,6 @@ interface AuthSession {
}
}
interface QueryResponse {
message: string
}
interface QueryToolDefinition {
name: string
label: string
@@ -60,6 +66,219 @@ 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> {
if (wantsHelp()) {
printUsage()
@@ -111,73 +330,72 @@ async function runChatLoop(
cookies: CookieJar,
session: AuthSession,
): Promise<void> {
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
console.log("Connected to /api/agent/ws")
printHelp()
for (;;) {
const input = askOptional("you> ")?.trim()
if (!input) continue
try {
for (;;) {
const input = askOptional("you> ")?.trim()
if (!input) continue
if (input === "/quit" || input === "/exit") {
console.log("Bye.")
return
}
if (input === "/quit" || input === "/exit") {
console.log("Bye.")
return
}
if (input === "/help") {
printHelp()
continue
}
if (input === "/help") {
printHelp()
continue
}
if (input === "/session") {
console.log(`${session.user.name || session.user.email} (${session.user.id})`)
continue
}
if (input === "/session") {
console.log(`${session.user.name || session.user.email} (${session.user.id})`)
continue
}
if (input === "/tools") {
await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue
}
if (input === "/conversation") {
console.log(agent.describeConversation())
continue
}
if (input.startsWith("/tool ")) {
await runCliCommand(() => executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)))
continue
}
if (input === "/tools") {
await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue
}
if (input.startsWith("/actions ")) {
await runCliCommand(() =>
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
)
continue
}
if (input.startsWith("/tool ")) {
await runCliCommand(() =>
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
)
continue
}
if (input.startsWith("/action ")) {
await runCliCommand(() =>
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
)
continue
}
if (input.startsWith("/actions ")) {
await runCliCommand(() =>
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
)
continue
}
try {
await askAgent(backendUrl, cookies, input)
} catch (err) {
console.error(`\n${formatError(err)}\n`)
if (input.startsWith("/action ")) {
await runCliCommand(() =>
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
)
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> {
try {
await command()
@@ -327,7 +545,7 @@ async function requestJson(
function printIntro(): void {
console.log("FREYA agent test CLI")
console.log("Connect to a backend, sign in, then send test messages to /api/agent.\n")
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n")
}
function printUsage(): void {
@@ -348,6 +566,7 @@ function printHelp(): void {
console.log(" /tool Execute an agent debug tool with JSON params")
console.log(" /actions List source actions: /actions <source-id>")
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(" /help Show commands")
console.log(" /quit Exit\n")
@@ -417,6 +636,33 @@ 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 {
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
}
@@ -511,6 +757,25 @@ function splitSetCookieHeader(header: string): string[] {
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> {
const text = await response.text()
if (response.status === 404 && path === "/api/agent") {
@@ -548,11 +813,6 @@ 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 {
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
return value.tools.every(isQueryToolDefinition)
@@ -585,6 +845,33 @@ 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 {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View File

@@ -1 +1,49 @@
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");--> statement-breakpoint
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,6 +16,7 @@
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.79.1",
"@freya/agent-protocol": "workspace:*",
"@freya/core": "workspace:*",
"@freya/source-caldav": "workspace:*",
"@freya/source-google-calendar": "workspace:*",
@@ -25,6 +26,7 @@
"@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*",
"@freya/source-web-search": "workspace:*",
"@nym.sh/jrpc": "^0.1.0",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",

View File

@@ -44,6 +44,20 @@ 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 {
return {
id,

View File

@@ -0,0 +1,347 @@
import { ConversationEntryKind } from "@freya/core"
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 { 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

@@ -0,0 +1,256 @@
import type { ConversationEntryMetadata } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { randomUUID } from "node:crypto"
import type {
AppendConversationEntryInput,
ConversationEntryRow,
} from "../conversations/storage.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactionEvent,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
/** Storage operations used to persist and replay query-agent conversation entries. */
export interface ConversationStorage {
getOrCreateConversation(): Promise<{ id: string }>
appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry>
listEntries(conversationId: string): Promise<ConversationStorageEntry[]>
}
/** Minimal persisted entry shape needed by recording and replay agents. */
export type ConversationStorageEntry = Pick<
ConversationEntryRow,
"id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt"
>
/** Configuration for wrapping a QueryAgent with conversation recording. */
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,7 +3,13 @@ import { Hono } from "hono"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type {
QueryAgent,
QueryAgentAsk,
QueryAgentEventListener,
QueryAgentStreamEvent,
QueryAgentEvent,
} from "./query-agent.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
@@ -12,19 +18,26 @@ const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentEvent[]
private readonly events: QueryAgentStreamEvent[]
constructor(events: QueryAgentEvent[]) {
constructor(events: QueryAgentStreamEvent[]) {
this.events = events
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
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 {}
}
@@ -110,6 +123,27 @@ describe("POST /api/agent", () => {
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 () => {
const app = buildTestApp(new FakeQueryAgent([]), "user-1")

View File

@@ -35,6 +35,7 @@ interface AgentDebugHttpHandlersDeps {
const AgentAskRequestBody = type({
"+": "reject",
message: "string",
"conversationId?": "string",
})
export function registerAgentHttpHandlers(
@@ -82,6 +83,7 @@ async function handleAgentAsk(c: Context<Env>) {
const session = await sessionManager.getOrCreate(user.id)
const response = await collectQueryAgentResponse(session.agent, {
message: parsed.message,
conversationId: parsed.conversationId,
})
return c.json(response)
} catch (err) {

View File

@@ -1,43 +0,0 @@
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,7 +1,10 @@
import { ConversationEntryKind } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgentEvent } from "./query-agent.ts"
import type { QueryAgentStreamEvent } from "./query-agent.ts"
import { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession {
subscribe(listener: (event: unknown) => void): () => void
@@ -9,6 +12,61 @@ interface FakePiSession {
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 createAgentSessionOptions: unknown
let runtimeApiKeyCalls: Array<{ provider: string; apiKey: string }> = []
@@ -51,6 +109,44 @@ const fakeSession: FakePiSession = {
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", () => ({
AuthStorage: {
inMemory() {
@@ -71,6 +167,7 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
createExtensionRuntime() {
return {}
},
DefaultResourceLoader: FakeDefaultResourceLoader,
defineTool(tool: unknown): unknown {
return tool
},
@@ -86,7 +183,7 @@ mock.module("@earendil-works/pi-coding-agent", () => ({
},
SessionManager: {
inMemory(_cwd: string): unknown {
return {}
return new FakeSessionManager()
},
},
SettingsManager: {
@@ -131,7 +228,6 @@ describe("PiQueryAgent", () => {
test("rejects a concurrent first query while the Pi session is being created", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
userId: "user-1",
toolbox: createStubToolbox(),
apiKey: "test-api-key",
cwd: "/tmp/freya-pi-query-agent-test",
@@ -155,7 +251,7 @@ describe("PiQueryAgent", () => {
expect(secondEvents).toEqual([
{
type: "error",
message: "A query is already running for this user",
message: "A query is already running",
},
])
expect(createAgentSessionCalls).toBe(1)
@@ -175,6 +271,228 @@ describe("PiQueryAgent", () => {
}
expect("agentDir" in createAgentSessionOptions).toBe(false)
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()
})
@@ -182,7 +500,6 @@ describe("PiQueryAgent", () => {
test("surfaces Pi message_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
userId: "user-1",
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
@@ -219,7 +536,6 @@ describe("PiQueryAgent", () => {
test("surfaces Pi agent_end provider errors instead of done", async () => {
const { PiQueryAgent } = await import("./pi-query-agent.ts")
const agent = new PiQueryAgent({
userId: "user-1",
toolbox: createStubToolbox(),
cwd: "/tmp/freya-pi-query-agent-test",
systemPrompt: "test",
@@ -256,8 +572,10 @@ describe("PiQueryAgent", () => {
})
})
async function collectEvents(events: AsyncIterable<QueryAgentEvent>): Promise<QueryAgentEvent[]> {
const result: QueryAgentEvent[] = []
async function collectEvents(
events: AsyncIterable<QueryAgentStreamEvent>,
): Promise<QueryAgentStreamEvent[]> {
const result: QueryAgentStreamEvent[] = []
for await (const event of events) {
result.push(event)
}
@@ -290,6 +608,79 @@ 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> {
return typeof value === "object" && value !== null
}

View File

@@ -1,73 +1,131 @@
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"
import type {
AgentSessionEvent,
ExtensionFactory,
SessionEntry,
} from "@earendil-works/pi-coding-agent"
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
ModelRegistry,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
import defaultSystemPrompt from "./prompts/system.txt"
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"
/** Active Pi SDK session instance returned by createAgentSession. */
type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"]
/** Pi event emitted when a message finishes. */
type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>
/** Message payload carried by Pi's message-end event. */
type PiAgentMessage = PiMessageEndEvent["message"]
/** Pi event emitted when an agent run finishes. */
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
/** Session manager created for Pi conversation replay. */
type PiSessionManager = ReturnType<typeof createSessionManager>
/** Message shape accepted by the replay session manager. */
type PiSessionMessage = Parameters<PiSessionManager["appendMessage"]>[0]
/** Configuration for the Pi-backed query agent. */
export interface PiQueryAgentConfig {
userId: string
toolbox: QueryAgentToolbox
apiKey?: string
cwd?: string
systemPrompt?: string
initialEntries?: ConversationStorageEntry[]
}
const MODEL_PROVIDER = "openrouter"
const MODEL_ID = "z-ai/glm-4.7-flash"
export const PI_MODEL_PROVIDER = "openrouter"
export const PI_MODEL_ID = "z-ai/glm-4.7-flash"
export class PiQueryAgent implements QueryAgent {
private readonly userId: string
private readonly toolbox: QueryAgentToolbox
private readonly cwd: string
private readonly systemPrompt: string
private readonly apiKey: string | undefined
private readonly initialEntries: ConversationStorageEntry[]
private readonly eventListeners = createQueryAgentEventListeners()
private session: 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
constructor(config: PiQueryAgentConfig) {
this.userId = config.userId
this.toolbox = config.toolbox
this.apiKey = config.apiKey
this.cwd = config.cwd ?? tmpdir()
this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt
this.initialEntries = config.initialEntries ?? []
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
if (this.activeRun) {
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
if (this.activeConversationId !== null) {
yield {
type: "error",
message: "A query is already running for this user",
message: "A query is already running",
}
return
}
const run = Symbol(this.userId)
this.activeRun = run
this.activeConversationId = input.conversationId ?? ""
this.activeUserMessageEntry = input.userMessageEntry ?? null
let session: PiSession
try {
session = await this.getOrCreateSession()
} catch (err) {
this.clearActiveRun(run)
this.activeConversationId = null
this.activeUserMessageEntry = null
yield {
type: "error",
message: `Failed to create query session: ${errorMessage(err)}`,
@@ -75,11 +133,11 @@ export class PiQueryAgent implements QueryAgent {
return
}
const events: QueryAgentEvent[] = []
const events: QueryAgentStreamEvent[] = []
let closed = false
let wake: (() => void) | null = null
function push(event: QueryAgentEvent): void {
function push(event: QueryAgentStreamEvent): void {
events.push(event)
if (wake) {
wake()
@@ -88,7 +146,7 @@ export class PiQueryAgent implements QueryAgent {
}
let runFailed = false
function pushRunEvent(event: QueryAgentEvent): void {
function pushRunEvent(event: QueryAgentStreamEvent): void {
if (event.type === "error") {
if (runFailed) return
runFailed = true
@@ -108,7 +166,8 @@ export class PiQueryAgent implements QueryAgent {
this.handlePiEvent(event, pushRunEvent)
})
void this.runPrompt(session, input)
session
.prompt(input.message)
.then(() => {
if (runFailed) return
pushRunEvent({ type: "done" })
@@ -118,7 +177,8 @@ export class PiQueryAgent implements QueryAgent {
})
.finally(() => {
unsubscribe()
this.clearActiveRun(run)
this.activeConversationId = null
this.activeUserMessageEntry = null
close()
})
@@ -140,12 +200,19 @@ export class PiQueryAgent implements QueryAgent {
this.session?.dispose()
this.session = null
this.pendingSession = null
this.activeRun = null
this.activeConversationId = null
this.activeUserMessageEntry = null
this.clearEventListeners()
}
private clearActiveRun(run: symbol): void {
if (this.activeRun === run) {
this.activeRun = null
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
@@ -184,38 +251,200 @@ export class PiQueryAgent implements QueryAgent {
})
const authStorage = AuthStorage.inMemory()
if (this.apiKey) {
authStorage.setRuntimeApiKey(MODEL_PROVIDER, this.apiKey)
authStorage.setRuntimeApiKey(PI_MODEL_PROVIDER, this.apiKey)
}
const modelRegistry = ModelRegistry.inMemory(authStorage)
const model = modelRegistry.find(MODEL_PROVIDER, MODEL_ID)
const model = modelRegistry.find(PI_MODEL_PROVIDER, PI_MODEL_ID)
if (!model) {
throw new Error(`Pi model not found: ${MODEL_PROVIDER}/${MODEL_ID}`)
throw new Error(`Pi model not found: ${PI_MODEL_PROVIDER}/${PI_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({
cwd: this.cwd,
authStorage,
modelRegistry,
model,
resourceLoader: new InMemoryResourceLoader(this.systemPrompt),
resourceLoader,
settingsManager,
sessionManager: SessionManager.inMemory(this.cwd),
sessionManager,
noTools: "builtin",
customTools: createFreyaAgentTools({
toolbox: this.toolbox,
}),
tools: [...FREYA_AGENT_TOOL_NAMES],
tools: FREYA_AGENT_TOOL_NAMES,
})
return session
}
private async runPrompt(session: PiSession, input: QueryAgentAsk): Promise<void> {
await session.prompt(input.message)
/**
* Creates Pi's SessionManager and records Pi-id -> Freya-sequence mappings.
*
* 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) {
case "message_end": {
const message = piAssistantMessageError(event.message)
@@ -283,7 +512,6 @@ function piAssistantMessageError(message: PiAgentMessage): string | null {
case "toolUse":
return null
}
return null
default:
return null
}

View File

@@ -1,21 +1,74 @@
export interface QueryAgentAsk {
message: string
conversationId?: string
userMessageEntry?: QueryAgentConversationEntryRef
}
export type QueryAgentEvent =
export type QueryAgentStreamEvent =
| { type: "conversation"; conversationId: string }
| { type: "text_delta"; text: string }
| { type: "tool_start"; toolName: string }
| { type: "tool_end"; toolName: string; ok: boolean }
| { type: "done" }
| { 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 {
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent>
ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent>
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void
dispose(): void
}
export interface QueryAgentResponse {
message: string
conversationId?: string
}
export class QueryAgentError extends Error {
@@ -30,9 +83,13 @@ export async function collectQueryAgentResponse(
input: QueryAgentAsk,
): Promise<QueryAgentResponse> {
let message = ""
let conversationId: string | undefined
for await (const event of agent.ask(input)) {
switch (event.type) {
case "conversation":
conversationId = event.conversationId
break
case "text_delta":
message += event.text
break
@@ -45,5 +102,5 @@ export async function collectQueryAgentResponse(
}
}
return { message }
return { message, conversationId }
}

View File

@@ -0,0 +1,156 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.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

@@ -0,0 +1,191 @@
import { SessionManager } from "@earendil-works/pi-coding-agent"
import {
AssistantMessagePayload,
ContextSummaryPayload,
ConversationEntryKind,
UserMessagePayload,
} from "@freya/core"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
/** Message shape accepted by Pi's SessionManager.appendMessage API. */
type PiMessage = Parameters<SessionManager["appendMessage"]>[0]
/** Assistant message variant required when replaying stored assistant entries. */
type PiAssistantMessage = Extract<PiMessage, { role: "assistant" }>
/** Inputs required to rebuild a Pi session manager from stored conversation entries. */
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

@@ -0,0 +1,149 @@
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

@@ -0,0 +1,125 @@
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

@@ -0,0 +1,68 @@
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

@@ -0,0 +1,287 @@
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,16 +53,6 @@ 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.
* Pass userId to simulate an authenticated request, or omit to get 401.

View File

@@ -0,0 +1,11 @@
export class ConversationNotFoundError extends Error {
readonly conversationId: string
readonly userId: string
constructor(conversationId: string, userId: string) {
super(`Conversation "${conversationId}" not found for user "${userId}"`)
this.name = "ConversationNotFoundError"
this.conversationId = conversationId
this.userId = userId
}
}

View File

@@ -0,0 +1,333 @@
import { ConversationEntryKind, ConversationEntryVisibility } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import type {
ConversationEntryRow,
ConversationRow,
ListConversationEntriesParams,
} from "./storage.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { registerConversationsHttpHandlers } from "./http.ts"
const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
const ConversationId = "11111111-1111-4111-8111-111111111111"
const MissingConversationId = "22222222-2222-4222-8222-222222222222"
const conversationRowsByUser = new Map<string, ConversationRow[]>()
const conversationEntryRowsByUserAndConversation = new Map<string, ConversationEntryRow[]>()
const listEntriesCalls: Array<{
userId: string
conversationId: string
params: ListConversationEntriesParams
}> = []
mock.module("./storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async listConversations(): Promise<ConversationRow[]> {
return conversationRowsByUser.get(userId) ?? []
},
async listEntries(
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
listEntriesCalls.push({ userId, conversationId, params })
const rows = conversationEntryRowsByUserAndConversation.get(
conversationEntriesKey(userId, conversationId),
)
if (!rows) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) {
return rows.filter((row) => row.visibility === params.visibility)
}
return rows
},
}),
}))
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),
}
}
function createConversationEntryRow(
id: string,
conversationId: string,
sequence: number,
kind: ConversationEntryRow["kind"],
visibility: ConversationEntryRow["visibility"],
payload: ConversationEntryRow["payload"],
createdAt: string,
metadata: ConversationEntryRow["metadata"] = {},
fileId: string | null = null,
): ConversationEntryRow {
return {
id,
conversationId,
sequence,
kind,
visibility,
fileId,
payload,
metadata,
createdAt: new Date(createdAt),
}
}
function conversationEntriesKey(userId: string, conversationId: string): string {
return `${userId}:${conversationId}`
}
describe("GET /api/conversations", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
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: [],
})
})
})
describe("GET /api/conversations/:id/entries", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
test("returns 401 without auth", async () => {
const app = buildTestApp()
const res = await app.request("/api/conversations/conversation-1/entries")
expect(res.status).toBe(401)
})
test("returns user-visible entries for the authenticated user", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-user",
ConversationId,
1,
ConversationEntryKind.UserMessage,
ConversationEntryVisibility.UserVisible,
{
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
"2026-06-17T09:30:00.000Z",
),
createConversationEntryRow(
"entry-tool",
ConversationId,
2,
ConversationEntryKind.ToolCall,
ConversationEntryVisibility.Internal,
{
toolName: "freya_list_context",
input: {},
},
"2026-06-17T09:30:01.000Z",
),
createConversationEntryRow(
"entry-assistant",
ConversationId,
3,
ConversationEntryKind.AssistantMessage,
ConversationEntryVisibility.UserVisible,
{
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
"2026-06-17T09:30:02.000Z",
{ runId: "run-1" },
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: ConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({
entries: [
{
id: "entry-user",
conversationId: ConversationId,
sequence: 1,
kind: ConversationEntryKind.UserMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "user",
parts: [{ type: "text", text: "What is on today?" }],
},
metadata: {},
createdAt: "2026-06-17T09:30:00.000Z",
},
{
id: "entry-assistant",
conversationId: ConversationId,
sequence: 3,
kind: ConversationEntryKind.AssistantMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "assistant",
parts: [{ type: "text", text: "You have two calendar events." }],
},
metadata: { runId: "run-1" },
createdAt: "2026-06-17T09:30:02.000Z",
},
],
})
})
test("returns an empty list when the conversation has no user-visible entries", async () => {
conversationEntryRowsByUserAndConversation.set(
conversationEntriesKey(MockUserId, ConversationId),
[
createConversationEntryRow(
"entry-tool",
ConversationId,
1,
ConversationEntryKind.ToolResult,
ConversationEntryVisibility.Internal,
{ toolCallId: "call-1", output: { ok: true } },
"2026-06-17T09:30:00.000Z",
),
],
)
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${ConversationId}/entries`)
expect(res.status).toBe(200)
const body = (await res.json()) as { entries: unknown[] }
expect(body).toEqual({ entries: [] })
})
test("returns 404 for malformed conversation ids without querying storage", async () => {
const app = buildTestApp("user-1")
const res = await app.request("/api/conversations/missing-conversation/entries")
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
test("returns 404 when the conversation does not exist for the user", async () => {
const app = buildTestApp("user-1")
const res = await app.request(`/api/conversations/${MissingConversationId}/entries`)
expect(res.status).toBe(404)
expect(listEntriesCalls).toEqual([
{
userId: MockUserId,
conversationId: MissingConversationId,
params: { visibility: ConversationEntryVisibility.UserVisible },
},
])
const body = (await res.json()) as { error: string }
expect(body).toEqual({ error: "Conversation not found" })
})
})

View File

@@ -0,0 +1,104 @@
import type { Context, Hono } from "hono"
import { ConversationEntryVisibility } from "@freya/core"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { conversations } from "./storage.ts"
/** Hono environment populated by the conversations route middleware. */
type Env = {
Variables: {
db: Database
}
}
/** Serialized conversation summary returned by the list endpoint. */
interface ConversationSummaryResponse {
id: string
createdAt: string
updatedAt: string
}
/** Dependencies required to register conversation HTTP handlers. */
interface ConversationsHttpHandlersDeps {
db: Database
authSessionMiddleware: AuthSessionMiddleware
}
const ConversationIdParam = type("string.uuid")
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)
app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries)
}
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(
serializeConversation,
),
})
}
async function handleListEntries(c: Context<Env>) {
const user = c.get("user")!
const db = c.get("db")
const conversationId = c.req.param("id")
if (!conversationId) {
return c.json({ error: "Conversation not found" }, 404)
}
const parsedConversationId = ConversationIdParam(conversationId)
if (parsedConversationId instanceof type.errors) {
return c.json({ error: "Conversation not found" }, 404)
}
try {
const entries = await conversations(db, user.id).listEntries(parsedConversationId, {
visibility: ConversationEntryVisibility.UserVisible,
})
return c.json({
entries: entries.map((row) => ({
id: row.id,
conversationId: row.conversationId,
sequence: row.sequence,
kind: row.kind,
visibility: row.visibility,
fileId: row.fileId,
payload: row.payload,
metadata: row.metadata,
createdAt: row.createdAt.toISOString(),
})),
})
} catch (err) {
if (err instanceof ConversationNotFoundError) {
return c.json({ error: "Conversation not found" }, 404)
}
throw err
}
}
function serializeConversation(row: ConversationRow): ConversationSummaryResponse {
return {
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}

View File

@@ -0,0 +1,389 @@
import {
AssistantMessagePayload,
AttachmentPayload,
ConversationEntryKind,
ConversationEntryVisibility,
ContextSummaryPayload,
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
type ConversationEntryPayload,
} from "@freya/core"
import { type } from "arktype"
import { and, asc, desc, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts"
import {
conversationEntries,
conversations as conversationsTable,
files,
user,
} from "../db/schema.ts"
import { ConversationNotFoundError } from "./errors.ts"
const conversationEntryKind = type.enumerated(...Object.values(ConversationEntryKind))
const conversationEntryVisibility = type.enumerated(...Object.values(ConversationEntryVisibility))
/** Database row shape for a conversation owned by a user. */
export type ConversationRow = typeof conversationsTable.$inferSelect
/** Database row shape for an entry in a conversation timeline. */
export type ConversationEntryRow = typeof conversationEntries.$inferSelect
/** Database row shape for an uploaded file referenced by conversations. */
export type FileRow = typeof files.$inferSelect
/** Input required to create a stored file record. */
export interface CreateFileInput {
storageKey: string
originalName?: string
mimeType: string
sizeBytes: number
metadata?: Record<string, unknown>
}
/** Input for creating a file and appending its attachment entry together. */
export interface AppendAttachmentEntryInput {
file: CreateFileInput
payload: AttachmentPayload
visibility?: ConversationEntryVisibility
metadata?: ConversationEntryMetadata
}
/** Result returned after a file-backed attachment entry is appended. */
export interface AppendAttachmentEntryResult {
file: FileRow
entry: ConversationEntryRow
}
/** Common fields accepted when appending any conversation entry. */
interface AppendConversationEntryBase {
visibility?: ConversationEntryVisibility
metadata?: ConversationEntryMetadata
}
/** Discriminated input for appending any supported entry kind to a conversation. */
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
})
/** Filters accepted when listing conversation entries. */
export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibility
}
export function conversations(db: Database, userId: string) {
const storage = {
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 getConversation(conversationId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(
and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)),
)
.limit(1)
return rows[0] ?? null
},
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 = conversationEntryKind.assert(input.kind)
const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(kind),
)
const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadata.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) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
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 = AttachmentPayload.assert(input.payload)
const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
)
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
return db.transaction(async (tx) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
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[]> {
if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
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))
},
}
return storage
}
function payloadForKind(
kind: ConversationEntryKind,
payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload {
switch (kind) {
case ConversationEntryKind.UserMessage:
return UserMessagePayload.assert(payload)
case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayload.assert(payload)
case ConversationEntryKind.Attachment:
return AttachmentPayload.assert(payload)
case ConversationEntryKind.ContextSummary:
return ContextSummaryPayload.assert(payload)
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote:
return GenericObjectPayload.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 findConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
.for("update")
return rows[0] ?? null
}
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: ConversationEntryKind): ConversationEntryVisibility {
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,6 +1,16 @@
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "@freya/core"
import { sql } from "drizzle-orm"
import {
boolean,
check,
customType,
integer,
index,
jsonb,
pgTable,
@@ -61,6 +71,81 @@ 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
// ---------------------------------------------------------------------------

View File

@@ -85,6 +85,20 @@ 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
describe("GET /api/feed", () => {

View File

@@ -1,14 +1,17 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { createMiddleware } from "hono/factory"
import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createQueryDebugTools } from "./agent/debug-tools.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./agent/http.ts"
import { agentWebSocket, registerAgentWebSocketHandlers } from "./agent/ws.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts"
import { CalDavSourceProvider } from "./caldav/provider.ts"
import { registerConversationsHttpHandlers } from "./conversations/http.ts"
import { createDatabase } from "./db/index.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
@@ -81,6 +84,15 @@ function main() {
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(
"/api/auth/*",
cors({
@@ -118,6 +130,7 @@ function main() {
sessionManager,
authSessionMiddleware,
})
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
if (isDebugMode) {
registerDebugAgentHttpHandlers(app, {
authSessionMiddleware,
@@ -127,6 +140,12 @@ function main() {
}
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
registerAgentWebSocketHandlers(app, {
sessionManager,
authSessionMiddleware,
corsMiddleware: agentWebSocketCorsMiddleware,
})
process.on("SIGTERM", async () => {
sessionManager.dispose()
await closeDb()
@@ -142,4 +161,5 @@ export default {
port: 3000,
hostname: "0.0.0.0",
fetch: app.fetch,
websocket: agentWebSocket,
}

View File

@@ -1,9 +1,12 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location"
import { WeatherSource } from "@freya/source-weatherkit"
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 { FeedSourceProvider } from "./feed-source-provider.ts"
@@ -21,6 +24,8 @@ import { UserSessionManager } from "./user-session-manager.ts"
* Key = userId (or "*" for a default), value = array of enabled sourceIds.
*/
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. */
function setEnabledSources(sourceIds: string[]) {
@@ -37,6 +42,10 @@ function getEnabledSourceIds(userId: string): string[] {
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),
* `find()` returns a standard enabled row. Set to a specific value (including
@@ -111,6 +120,35 @@ 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 = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
@@ -160,6 +198,8 @@ const weatherProvider: FeedSourceProvider = {
beforeEach(() => {
enabledByUser.clear()
conversationEntriesByUser.clear()
mockConversationCalls.length = 0
mockFindResult = undefined
mockUpdateCredentialsCalls.length = 0
mockUpdateCredentialsError = null
@@ -176,6 +216,31 @@ describe("UserSessionManager", () => {
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 () => {
setEnabledSources(["freya.location"])
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })

View File

@@ -8,6 +8,7 @@ import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { CredentialEncryptor } from "../lib/crypto.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { conversations } from "../conversations/storage.ts"
import {
CredentialStorageUnavailableError,
InvalidSourceConfigError,
@@ -362,6 +363,7 @@ export class UserSessionManager {
private async createSession(userId: string): Promise<UserSession> {
const enabledRows = await sources(this.db, userId).enabled()
const agentConfig = this.queryAgentConfigForUser(userId)
const promises: Promise<FeedSource>[] = []
for (const row of enabledRows) {
@@ -373,7 +375,7 @@ export class UserSessionManager {
}
if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
return this.initializedSession(userId, [], agentConfig)
}
const results = await Promise.allSettled(promises)
@@ -397,7 +399,29 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(userId, feedSources, this.feedEnhancer, this.queryAgentConfig)
return this.initializedSession(userId, feedSources, agentConfig)
}
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

@@ -1,8 +1,15 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location"
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 { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
@@ -23,6 +30,40 @@ 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", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession("test-user", [
@@ -67,6 +108,32 @@ describe("UserSession", () => {
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 () => {
const location = new LocationSource()
const session = new UserSession("test-user", [location])

View File

@@ -10,22 +10,30 @@ import type { QueryAgentToolbox } from "../agent/query-agent-toolbox.ts"
import type { QueryAgent } from "../agent/query-agent.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import { PiQueryAgent } from "../agent/pi-query-agent.ts"
import {
ConversationRecordingQueryAgent,
type ConversationStorage,
} from "../agent/conversation-recording-query-agent.ts"
import { PiQueryAgent, PI_MODEL_ID, PI_MODEL_PROVIDER } from "../agent/pi-query-agent.ts"
import { UserSessionQueryAgentToolbox } from "../agent/user-session-query-agent-toolbox.ts"
export interface UserSessionAgentConfig {
apiKey?: string
cwd?: string
systemPrompt?: string
conversationStorage?: ConversationStorage
}
export class UserSession {
readonly userId: string
readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>()
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
/** The FeedResult that enhancedItems was derived from. */
private enhancedSource: FeedResult | null = null
@@ -41,6 +49,7 @@ export class UserSession {
this.userId = userId
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
this.agentConfig = agentConfig
for (const source of sources) {
this.sources.set(source.id, source)
this.engine.register(source)
@@ -54,17 +63,43 @@ export class UserSession {
}
this.toolbox = new UserSessionQueryAgentToolbox(this)
this.agent = new PiQueryAgent({
userId: this.userId,
toolbox: this.toolbox,
apiKey: agentConfig?.apiKey,
cwd: agentConfig?.cwd,
systemPrompt: agentConfig?.systemPrompt,
})
if (!agentConfig?.conversationStorage) {
this.queryAgent = new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
})
this.initialized = true
}
this.engine.start()
}
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.
* Enhancement runs eagerly on engine updates; this method awaits
@@ -201,7 +236,8 @@ export class UserSession {
}
destroy(): void {
this.agent.dispose()
this.queryAgent?.dispose()
this.queryAgent = null
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()
@@ -210,6 +246,38 @@ export class UserSession {
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 {
this.enhancedItems = null
this.enhancedSource = null

View File

@@ -128,6 +128,20 @@ 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 = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database

View File

@@ -1,13 +1,12 @@
{
"expo": {
"name": "Freya",
"slug": "freya-client",
"slug": "freya",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "freya",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
@@ -24,7 +23,6 @@
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.freya"
},
@@ -54,55 +52,82 @@
{
"fontFamily": "Inter",
"fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{
"path": "./assets/fonts/Inter_100Thin.ttf",
"weight": 100
},
{
"path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100,
"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",
"weight": 200,
"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",
"weight": 300,
"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",
"weight": 400,
"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",
"weight": 500,
"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",
"weight": 600,
"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",
"weight": 700,
"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",
"weight": 800,
"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",
"weight": 900,
@@ -113,49 +138,73 @@
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"weight": 200
},
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200,
"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",
"weight": 300,
"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",
"weight": 400,
"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",
"weight": 500,
"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",
"weight": 600,
"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",
"weight": 700,
"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",
"weight": 800,
"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",
"weight": 900,
@@ -204,7 +253,9 @@
]
}
}
]
],
"expo-web-browser",
"expo-image"
],
"experiments": {
"typedRoutes": true,
@@ -213,7 +264,7 @@
"extra": {
"router": {},
"eas": {
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
"projectId": "c54ea4e5-27da-4066-b081-db8005ecf70a"
}
}
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
import { $ } from "bun"
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 base = `http://${tsIp}:${PROXY_PORT}`
@@ -37,9 +36,7 @@ if (!target) {
process.exit(1)
}
const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const wsUrl = getProxyWebSocketPath(target.webSocketDebuggerUrl)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
@@ -71,6 +68,11 @@ function isDebugTarget(value: unknown): value is DebugTarget {
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> {
return typeof value === "object" && value !== null
}

View File

@@ -1,14 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
PROXY_PORT=8080
METRO_PORT=8081
PROXY_PORT=${PROXY_PORT:-8080}
METRO_HOST=${METRO_HOST:-localhost}
METRO_PORT=${METRO_PORT:-8081}
TS_IP=$(tailscale ip -4)
# Start a reverse proxy so Metro sees all requests as loopback.
# 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 &
port_is_open() {
(: >"/dev/tcp/$1/$2") >/dev/null 2>&1
}
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=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

1849
bun.lock

File diff suppressed because it is too large Load Diff

2
bunfig.toml Normal file
View File

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

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"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 Normal file
View File

@@ -0,0 +1,282 @@
{
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,6 +10,7 @@
"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",
"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",
"agent-test-cli": "cd apps/agent-test-cli && bun run start",
"test": "bun run --filter '*' test",

View File

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,21 @@
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

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

View File

@@ -8,7 +8,8 @@
"test": "bun test ."
},
"dependencies": {
"@standard-schema/spec": "^1.1.0"
"@standard-schema/spec": "^1.1.0",
"arktype": "^2.1.29"
},
"peerDependencies": {
"@json-render/core": "*",

View File

@@ -0,0 +1,146 @@
import { describe, expect, test } from "bun:test"
import {
AttachmentType,
AttachmentPayload,
ContextSummaryPayload,
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
} from "./conversation"
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

@@ -0,0 +1,161 @@
import { type } from "arktype"
/** Entry kinds supported by the persisted conversation timeline. */
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
/** Discriminator for the payload shape and handling of a conversation entry. */
export type ConversationEntryKind =
(typeof ConversationEntryKind)[keyof typeof ConversationEntryKind]
/** Visibility scopes supported by stored conversation entries. */
export const ConversationEntryVisibility = {
UserVisible: "user_visible",
Internal: "internal",
} as const
/** Indicates whether a conversation entry should be exposed to the user. */
export type ConversationEntryVisibility =
(typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility]
/** Attachment media categories accepted by conversation entries. */
export const AttachmentType = {
Image: "image",
Audio: "audio",
Video: "video",
Document: "document",
Other: "other",
} as const
/** File or media category associated with an attachment payload. */
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
/** Plain text content part for a message. */
export const TextMessagePart = type({
"+": "reject",
type: "'text'",
text: "string",
})
/** Structured JSON content part for a message. */
export const JsonMessagePart = type({
"+": "reject",
type: "'json'",
value: "unknown",
})
/** Content part variants supported by user and assistant messages. */
export const MessagePart = type.or(TextMessagePart, JsonMessagePart)
/** A structured content part inside a user or assistant message payload. */
export type MessagePart = typeof MessagePart.infer
/** User-authored message entry payload. */
export const UserMessagePayload = type({
"+": "reject",
role: "'user'",
parts: MessagePart.array().atLeastLength(1),
})
/** Payload stored for a conversation entry containing a user message. */
export type UserMessagePayload = typeof UserMessagePayload.infer
/** Assistant-authored message entry payload. */
export const AssistantMessagePayload = type({
"+": "reject",
role: "'assistant'",
parts: MessagePart.array().atLeastLength(1),
})
/** Payload stored for a conversation entry containing an assistant message. */
export type AssistantMessagePayload = typeof AssistantMessagePayload.infer
/** Attachment entry payload. */
export const AttachmentPayload = type({
"+": "reject",
role: type.enumerated("user", "assistant"),
name: "string",
mimeType: "string",
attachmentType: type.enumerated(...Object.values(AttachmentType)),
"caption?": "string",
})
/** Payload stored for a conversation entry that references an uploaded file. */
export type AttachmentPayload = typeof AttachmentPayload.infer
/** Durable facts extracted from compacted conversation history. */
export 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(),
})
/** Durable facts and follow-ups retained from compacted conversation history. */
export type ContextSummary = typeof ContextSummary.infer
/** Context-summary conversation entry payload. */
export const ContextSummaryPayload = type({
"+": "reject",
covers: type({
"+": "reject",
startSequence: "number.integer >= 1",
endSequence: "number.integer >= 1",
}),
summary: ContextSummary,
promptVersion: "string",
"sourceEntryIds?": type.string.array(),
})
/** Payload describing a compaction summary and the sequence range it covers. */
export type ContextSummaryPayload = typeof ContextSummaryPayload.infer
/** Model invocation metadata recorded on generated entries. */
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",
})
/** Metadata describing the model run that produced a conversation entry. */
export type ModelRunMetadata = typeof ModelRunMetadata.infer
/** Arbitrary metadata stored alongside conversation entries. */
export const ConversationEntryMetadata = type({
"modelRun?": ModelRunMetadata,
"[string]": "unknown",
})
/** Metadata bag attached to a conversation entry. */
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer
/** Generic object payload used by operational entries. */
export const GenericObjectPayload = type("Record<string, unknown>")
/** Fallback payload shape for tool calls, tool results, and system notes. */
export type GenericObjectPayload = typeof GenericObjectPayload.infer
/** Union of payload shapes that can be stored on a conversation entry. */
export type ConversationEntryPayload =
| UserMessagePayload
| AssistantMessagePayload
| AttachmentPayload
| ContextSummaryPayload
| GenericObjectPayload

View File

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

View File

@@ -6,6 +6,25 @@ export { Context, contextKey, serializeKey } from "./context"
export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Conversation
export type { ConversationEntryPayload } from "./conversation"
export {
AssistantMessagePayload,
AttachmentPayload,
AttachmentType,
ContextSummary,
ContextSummaryPayload,
ConversationEntryKind,
ConversationEntryMetadata,
ConversationEntryVisibility,
GenericObjectPayload,
JsonMessagePart,
MessagePart,
ModelRunMetadata,
TextMessagePart,
UserMessagePayload,
} from "./conversation"
// Feed
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
export { TimeRelevance } from "./feed"