Compare commits

..

10 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
45 changed files with 2566 additions and 1821 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,8 +330,11 @@ async function runChatLoop(
cookies: CookieJar,
session: AuthSession,
): Promise<void> {
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
console.log("Connected to /api/agent/ws")
printHelp()
try {
for (;;) {
const input = askOptional("you> ")?.trim()
if (!input) continue
@@ -132,13 +354,20 @@ async function runChatLoop(
continue
}
if (input === "/conversation") {
console.log(agent.describeConversation())
continue
}
if (input === "/tools") {
await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue
}
if (input.startsWith("/tool ")) {
await runCliCommand(() => executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)))
await runCliCommand(() =>
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
)
continue
}
@@ -157,25 +386,14 @@ async function runChatLoop(
}
try {
await askAgent(backendUrl, cookies, input)
await agent.ask(input)
} catch (err) {
console.error(`\n${formatError(err)}\n`)
}
}
}
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")
} finally {
agent.close()
}
console.log(`\nagent> ${data.message || "(no message)"}`)
console.log("")
}
async function runCliCommand(command: () => Promise<void>): Promise<void> {
@@ -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

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

@@ -1,3 +1,4 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
@@ -6,7 +7,6 @@ import type {
ConversationStorageEntry,
} from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { ConversationRecordingQueryAgent } from "./conversation-recording-query-agent.ts"
import {
createQueryAgentEventListeners,

View File

@@ -1,12 +1,13 @@
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 type { ConversationEntryMetadata } from "../conversations/types.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
@@ -19,6 +20,7 @@ import {
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(
@@ -28,11 +30,13 @@ export interface ConversationStorage {
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

View File

@@ -1,9 +1,9 @@
import { ConversationEntryKind } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
import type { QueryAgentStreamEvent } from "./query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { QueryAgentEvent } from "./query-agent.ts"
interface FakePiSession {

View File

@@ -33,13 +33,25 @@ import {
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 {
toolbox: QueryAgentToolbox
apiKey?: string

View File

@@ -1,8 +1,8 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { createSessionManager } from "./session-manager.ts"
describe("createSessionManager", () => {

View File

@@ -1,18 +1,21 @@
import { SessionManager } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import {
AssistantMessagePayload,
ContextSummaryPayload,
ConversationEntryKind,
UserMessagePayload,
} from "../conversations/types.ts"
} 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[]

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

@@ -1,17 +1,18 @@
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 type {
AssistantMessagePayload,
AttachmentPayload,
ContextSummaryPayload,
ConversationEntryKind as ConversationEntryKindType,
ConversationEntryMetadata,
ConversationEntryPayload,
ConversationEntryVisibility as ConversationEntryVisibilityType,
GenericObjectPayload,
UserMessagePayload,
} from "./types.ts"
import {
conversationEntries,
@@ -19,23 +20,21 @@ import {
files,
user,
} from "../db/schema.ts"
import {
ConversationEntryMetadata as ConversationEntryMetadataSchema,
AssistantMessagePayload as AssistantMessagePayloadSchema,
AttachmentPayload as AttachmentPayloadSchema,
ConversationEntryKind,
ConversationEntryKindInput,
ConversationEntryVisibility,
ConversationEntryVisibilityInput,
ContextSummaryPayload as ContextSummaryPayloadSchema,
GenericObjectPayload as GenericObjectPayloadSchema,
UserMessagePayload as UserMessagePayloadSchema,
} from "./types.ts"
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
@@ -44,23 +43,27 @@ export interface CreateFileInput {
metadata?: Record<string, unknown>
}
/** Input for creating a file and appending its attachment entry together. */
export interface AppendAttachmentEntryInput {
file: CreateFileInput
payload: AttachmentPayload
visibility?: ConversationEntryVisibilityType
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?: ConversationEntryVisibilityType
visibility?: ConversationEntryVisibility
metadata?: ConversationEntryMetadata
}
/** Discriminated input for appending any supported entry kind to a conversation. */
export type AppendConversationEntryInput =
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.UserMessage
@@ -91,16 +94,37 @@ export type AppendConversationEntryInput =
fileId?: never
})
/** Filters accepted when listing conversation entries. */
export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibilityType
visibility?: ConversationEntryVisibility
}
export function conversations(db: Database, userId: string) {
return {
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)
@@ -119,12 +143,12 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationEntryRow> {
const kind = ConversationEntryKindInput.assert(input.kind)
const visibility = ConversationEntryVisibilityInput.assert(
const kind = conversationEntryKind.assert(input.kind)
const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(kind),
)
const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
let fileId: string | null = null
if (input.kind === ConversationEntryKind.Attachment) {
@@ -133,7 +157,9 @@ export function conversations(db: Database, userId: string) {
}
const rows = await db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
@@ -160,14 +186,16 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
input: AppendAttachmentEntryInput,
): Promise<AppendAttachmentEntryResult> {
const payload = AttachmentPayloadSchema.assert(input.payload)
const visibility = ConversationEntryVisibilityInput.assert(
const payload = AttachmentPayload.assert(input.payload)
const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
)
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {})
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
return db.transaction(async (tx) => {
await requireConversationForUpdate(tx, userId, conversationId)
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)
@@ -196,7 +224,9 @@ export function conversations(db: Database, userId: string) {
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
await requireConversation(db, userId, conversationId)
if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) {
return db
@@ -218,25 +248,27 @@ export function conversations(db: Database, userId: string) {
.orderBy(asc(conversationEntries.sequence))
},
}
return storage
}
function payloadForKind(
kind: ConversationEntryKindType,
kind: ConversationEntryKind,
payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload {
switch (kind) {
case ConversationEntryKind.UserMessage:
return UserMessagePayloadSchema.assert(payload)
return UserMessagePayload.assert(payload)
case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayloadSchema.assert(payload)
return AssistantMessagePayload.assert(payload)
case ConversationEntryKind.Attachment:
return AttachmentPayloadSchema.assert(payload)
return AttachmentPayload.assert(payload)
case ConversationEntryKind.ContextSummary:
return ContextSummaryPayloadSchema.assert(payload)
return ContextSummaryPayload.assert(payload)
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote:
return GenericObjectPayloadSchema.assert(payload)
return GenericObjectPayload.assert(payload)
}
}
@@ -251,25 +283,11 @@ async function requireUserForUpdate(db: Database, userId: string): Promise<void>
requireRow(rows, `User not found: ${userId}`)
}
async function requireConversation(
async function findConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function requireConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
@@ -277,7 +295,7 @@ async function requireConversationForUpdate(
.limit(1)
.for("update")
return requireRow(rows, `Conversation not found: ${conversationId}`)
return rows[0] ?? null
}
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {
@@ -356,9 +374,7 @@ function requireRow<T>(rows: T[], message = "Expected database row"): T {
return row
}
function defaultVisibilityForKind(
kind: ConversationEntryKindType,
): ConversationEntryVisibilityType {
function defaultVisibilityForKind(kind: ConversationEntryKind): ConversationEntryVisibility {
switch (kind) {
case ConversationEntryKind.UserMessage:
case ConversationEntryKind.AssistantMessage:

View File

@@ -1,3 +1,10 @@
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "@freya/core"
import { sql } from "drizzle-orm"
import {
boolean,
@@ -13,14 +20,6 @@ import {
uuid,
} from "drizzle-orm/pg-core"
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "../conversations/types.ts"
// ---------------------------------------------------------------------------
// Better Auth core tables
// Re-exported from CLI-generated schema.

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,5 +1,6 @@
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"
@@ -9,7 +10,6 @@ import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { CredentialEncryptor } from "../lib/crypto.ts"
import {
CredentialStorageUnavailableError,

View File

@@ -1,5 +1,6 @@
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"
@@ -9,7 +10,6 @@ import type {
} from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {

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

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

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

@@ -7,7 +7,7 @@ import {
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
} from "./types.ts"
} from "./conversation"
describe("conversation entry schemas", () => {
test("parses valid user message payloads", () => {

View File

@@ -1,5 +1,6 @@
import { type } from "arktype"
/** Entry kinds supported by the persisted conversation timeline. */
export const ConversationEntryKind = {
UserMessage: "user_message",
AssistantMessage: "assistant_message",
@@ -10,17 +11,21 @@ export const ConversationEntryKind = {
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",
@@ -29,57 +34,64 @@ export const AttachmentType = {
Other: "other",
} as const
/** File or media category associated with an attachment payload. */
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
export const ConversationEntryKindInput = type.enumerated(...Object.values(ConversationEntryKind))
export const ConversationEntryVisibilityInput = type.enumerated(
...Object.values(ConversationEntryVisibility),
)
export const AttachmentTypeInput = type.enumerated(...Object.values(AttachmentType))
const TextMessagePart = type({
/** Plain text content part for a message. */
export const TextMessagePart = type({
"+": "reject",
type: "'text'",
text: "string",
})
const JsonMessagePart = type({
/** 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: AttachmentTypeInput,
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
const ContextSummary = type({
/** Durable facts extracted from compacted conversation history. */
export const ContextSummary = type({
"+": "reject",
"userIntent?": "string",
durableFacts: type.string.array(),
@@ -89,6 +101,10 @@ const ContextSummary = type({
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({
@@ -101,8 +117,10 @@ export const ContextSummaryPayload = type({
"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",
@@ -116,18 +134,25 @@ export const ModelRunMetadata = type({
"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

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,11 +817,14 @@ 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)
try {
engine.start()
// Initial reactive update
@@ -820,15 +833,19 @@ describe("FeedEngine", () => {
expect(engine.lastFeed()).not.toBeNull()
// Wait 70ms (total 120ms from first update, past original TTL)
// but trigger another update at 50ms to reset TTL
// 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
// Should still be cached because TTL was reset by second update.
expect(engine.lastFeed()).not.toBeNull()
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)
const engine = new FeedEngine({ cacheTtlMs: 20 }).register(source)
await engine.refresh()
expect(fetchCount).toBe(1)
try {
engine.start()
// Wait for two TTL intervals to elapse
await new Promise((resolve) => setTimeout(resolve, 120))
await waitForCondition(() => fetchCount >= 2)
// Should have auto-refreshed at least twice
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")
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))
await waitForCondition(() => fetchCount > countBeforeUpdate && engine.lastFeed() !== null)
// Reactive update triggered a fetch
// Reactive updates refresh the cache and reset the pending periodic timer.
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)
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"