Compare commits

..

6 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
30 changed files with 969 additions and 1717 deletions

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import type { ConversationEntryMetadata } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import type { import type {
AppendConversationEntryInput, AppendConversationEntryInput,
ConversationEntryRow, ConversationEntryRow,
} from "../conversations/storage.ts" } from "../conversations/storage.ts"
import type { ConversationEntryMetadata } from "../conversations/types.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { import {
createQueryAgentEventListeners, createQueryAgentEventListeners,
QueryAgentEvent, QueryAgentEvent,
@@ -19,6 +20,7 @@ import {
type QueryAgentStreamEvent, type QueryAgentStreamEvent,
} from "./query-agent.ts" } from "./query-agent.ts"
/** Storage operations used to persist and replay query-agent conversation entries. */
export interface ConversationStorage { export interface ConversationStorage {
getOrCreateConversation(): Promise<{ id: string }> getOrCreateConversation(): Promise<{ id: string }>
appendEntry( appendEntry(
@@ -28,11 +30,13 @@ export interface ConversationStorage {
listEntries(conversationId: string): Promise<ConversationStorageEntry[]> listEntries(conversationId: string): Promise<ConversationStorageEntry[]>
} }
/** Minimal persisted entry shape needed by recording and replay agents. */
export type ConversationStorageEntry = Pick< export type ConversationStorageEntry = Pick<
ConversationEntryRow, ConversationEntryRow,
"id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt" "id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt"
> >
/** Configuration for wrapping a QueryAgent with conversation recording. */
export interface ConversationRecordingQueryAgentConfig { export interface ConversationRecordingQueryAgentConfig {
agent: QueryAgent agent: QueryAgent
storage: ConversationStorage storage: ConversationStorage

View File

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

View File

@@ -33,13 +33,25 @@ import {
import { createSessionManager } from "./session-manager.ts" import { createSessionManager } from "./session-manager.ts"
import { createFreyaAgentTools, FREYA_AGENT_TOOL_NAMES } from "./tools.ts" import { createFreyaAgentTools, FREYA_AGENT_TOOL_NAMES } from "./tools.ts"
/** Active Pi SDK session instance returned by createAgentSession. */
type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"] type PiSession = Awaited<ReturnType<typeof createAgentSession>>["session"]
/** Pi event emitted when a message finishes. */
type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }> type PiMessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>
/** Message payload carried by Pi's message-end event. */
type PiAgentMessage = PiMessageEndEvent["message"] type PiAgentMessage = PiMessageEndEvent["message"]
/** Pi event emitted when an agent run finishes. */
type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }> type PiAgentEndEvent = Extract<AgentSessionEvent, { type: "agent_end" }>
/** Session manager created for Pi conversation replay. */
type PiSessionManager = ReturnType<typeof createSessionManager> type PiSessionManager = ReturnType<typeof createSessionManager>
/** Message shape accepted by the replay session manager. */
type PiSessionMessage = Parameters<PiSessionManager["appendMessage"]>[0] type PiSessionMessage = Parameters<PiSessionManager["appendMessage"]>[0]
/** Configuration for the Pi-backed query agent. */
export interface PiQueryAgentConfig { export interface PiQueryAgentConfig {
toolbox: QueryAgentToolbox toolbox: QueryAgentToolbox
apiKey?: string apiKey?: string

View File

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

View File

@@ -1,18 +1,21 @@
import { SessionManager } from "@earendil-works/pi-coding-agent" import { SessionManager } from "@earendil-works/pi-coding-agent"
import { tmpdir } from "node:os"
import type { ConversationStorageEntry } from "./conversation-recording-query-agent.ts"
import { import {
AssistantMessagePayload, AssistantMessagePayload,
ContextSummaryPayload, ContextSummaryPayload,
ConversationEntryKind, ConversationEntryKind,
UserMessagePayload, 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] type PiMessage = Parameters<SessionManager["appendMessage"]>[0]
/** Assistant message variant required when replaying stored assistant entries. */
type PiAssistantMessage = Extract<PiMessage, { role: "assistant" }> type PiAssistantMessage = Extract<PiMessage, { role: "assistant" }>
/** Inputs required to rebuild a Pi session manager from stored conversation entries. */
export interface CreateSessionManagerInput { export interface CreateSessionManagerInput {
cwd?: string cwd?: string
entries: ConversationStorageEntry[] entries: ConversationStorageEntry[]

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

@@ -1,21 +1,55 @@
import { ConversationEntryKind, ConversationEntryVisibility } from "@freya/core"
import { beforeEach, describe, expect, mock, test } from "bun:test" import { beforeEach, describe, expect, mock, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts" import type {
ConversationEntryRow,
ConversationRow,
ListConversationEntriesParams,
} from "./storage.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { registerConversationsHttpHandlers } from "./http.ts" import { registerConversationsHttpHandlers } from "./http.ts"
const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn" const MockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
const ConversationId = "11111111-1111-4111-8111-111111111111"
const MissingConversationId = "22222222-2222-4222-8222-222222222222"
const conversationRowsByUser = new Map<string, ConversationRow[]>() 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", () => ({ mock.module("./storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({ conversations: (_db: Database, userId: string) => ({
async listConversations(): Promise<ConversationRow[]> { async listConversations(): Promise<ConversationRow[]> {
return conversationRowsByUser.get(userId) ?? [] 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
},
}), }),
})) }))
@@ -44,9 +78,39 @@ function createConversationRow(
} }
} }
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", () => { describe("GET /api/conversations", () => {
beforeEach(() => { beforeEach(() => {
conversationRowsByUser.clear() conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
}) })
test("returns 401 without auth", async () => { test("returns 401 without auth", async () => {
@@ -108,3 +172,162 @@ describe("GET /api/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

@@ -1,23 +1,38 @@
import type { Context, Hono } from "hono" import type { Context, Hono } from "hono"
import { ConversationEntryVisibility } from "@freya/core"
import { type } from "arktype"
import { createMiddleware } from "hono/factory" import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts"
import { ConversationNotFoundError } from "./errors.ts"
import { conversations } from "./storage.ts" import { conversations } from "./storage.ts"
/** Hono environment populated by the conversations route middleware. */
type Env = { type Env = {
Variables: { Variables: {
db: Database 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 { interface ConversationsHttpHandlersDeps {
db: Database db: Database
authSessionMiddleware: AuthSessionMiddleware authSessionMiddleware: AuthSessionMiddleware
} }
const ConversationIdParam = type("string.uuid")
export function registerConversationsHttpHandlers( export function registerConversationsHttpHandlers(
app: Hono, app: Hono,
{ db, authSessionMiddleware }: ConversationsHttpHandlersDeps, { db, authSessionMiddleware }: ConversationsHttpHandlersDeps,
@@ -28,6 +43,7 @@ export function registerConversationsHttpHandlers(
}) })
app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations) app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations)
app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries)
} }
async function handleListConversations(c: Context<Env>) { async function handleListConversations(c: Context<Env>) {
@@ -35,10 +51,54 @@ async function handleListConversations(c: Context<Env>) {
const db = c.get("db") const db = c.get("db")
return c.json({ return c.json({
conversations: (await conversations(db, user.id).listConversations()).map((row) => ({ 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, id: row.id,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.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 { and, asc, desc, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts" 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 { import {
conversationEntries, conversationEntries,
@@ -19,23 +20,21 @@ import {
files, files,
user, user,
} from "../db/schema.ts" } from "../db/schema.ts"
import { import { ConversationNotFoundError } from "./errors.ts"
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"
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 export type ConversationRow = typeof conversationsTable.$inferSelect
/** Database row shape for an entry in a conversation timeline. */
export type ConversationEntryRow = typeof conversationEntries.$inferSelect export type ConversationEntryRow = typeof conversationEntries.$inferSelect
/** Database row shape for an uploaded file referenced by conversations. */
export type FileRow = typeof files.$inferSelect export type FileRow = typeof files.$inferSelect
/** Input required to create a stored file record. */
export interface CreateFileInput { export interface CreateFileInput {
storageKey: string storageKey: string
originalName?: string originalName?: string
@@ -44,23 +43,27 @@ export interface CreateFileInput {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
/** Input for creating a file and appending its attachment entry together. */
export interface AppendAttachmentEntryInput { export interface AppendAttachmentEntryInput {
file: CreateFileInput file: CreateFileInput
payload: AttachmentPayload payload: AttachmentPayload
visibility?: ConversationEntryVisibilityType visibility?: ConversationEntryVisibility
metadata?: ConversationEntryMetadata metadata?: ConversationEntryMetadata
} }
/** Result returned after a file-backed attachment entry is appended. */
export interface AppendAttachmentEntryResult { export interface AppendAttachmentEntryResult {
file: FileRow file: FileRow
entry: ConversationEntryRow entry: ConversationEntryRow
} }
/** Common fields accepted when appending any conversation entry. */
interface AppendConversationEntryBase { interface AppendConversationEntryBase {
visibility?: ConversationEntryVisibilityType visibility?: ConversationEntryVisibility
metadata?: ConversationEntryMetadata metadata?: ConversationEntryMetadata
} }
/** Discriminated input for appending any supported entry kind to a conversation. */
export type AppendConversationEntryInput = export type AppendConversationEntryInput =
| (AppendConversationEntryBase & { | (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.UserMessage kind: typeof ConversationEntryKind.UserMessage
@@ -91,12 +94,13 @@ export type AppendConversationEntryInput =
fileId?: never fileId?: never
}) })
/** Filters accepted when listing conversation entries. */
export interface ListConversationEntriesParams { export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibilityType visibility?: ConversationEntryVisibility
} }
export function conversations(db: Database, userId: string) { export function conversations(db: Database, userId: string) {
return { const storage = {
async createConversation(): Promise<ConversationRow> { async createConversation(): Promise<ConversationRow> {
return insertConversation(db, userId) return insertConversation(db, userId)
}, },
@@ -109,6 +113,18 @@ export function conversations(db: Database, userId: string) {
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt)) .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> { async getOrCreateConversation(): Promise<ConversationRow> {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
await requireUserForUpdate(tx, userId) await requireUserForUpdate(tx, userId)
@@ -127,12 +143,12 @@ export function conversations(db: Database, userId: string) {
conversationId: string, conversationId: string,
input: AppendConversationEntryInput, input: AppendConversationEntryInput,
): Promise<ConversationEntryRow> { ): Promise<ConversationEntryRow> {
const kind = ConversationEntryKindInput.assert(input.kind) const kind = conversationEntryKind.assert(input.kind)
const visibility = ConversationEntryVisibilityInput.assert( const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(kind), input.visibility ?? defaultVisibilityForKind(kind),
) )
const payload = payloadForKind(kind, input.payload) const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {}) const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
let fileId: string | null = null let fileId: string | null = null
if (input.kind === ConversationEntryKind.Attachment) { if (input.kind === ConversationEntryKind.Attachment) {
@@ -141,7 +157,9 @@ export function conversations(db: Database, userId: string) {
} }
const rows = await db.transaction(async (tx) => { 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 sequence = await nextSequence(tx, conversationId)
const rows = await tx const rows = await tx
@@ -168,14 +186,16 @@ export function conversations(db: Database, userId: string) {
conversationId: string, conversationId: string,
input: AppendAttachmentEntryInput, input: AppendAttachmentEntryInput,
): Promise<AppendAttachmentEntryResult> { ): Promise<AppendAttachmentEntryResult> {
const payload = AttachmentPayloadSchema.assert(input.payload) const payload = AttachmentPayload.assert(input.payload)
const visibility = ConversationEntryVisibilityInput.assert( const visibility = conversationEntryVisibility.assert(
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment), input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
) )
const metadata = ConversationEntryMetadataSchema.assert(input.metadata ?? {}) const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
return db.transaction(async (tx) => { 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 file = await insertFile(tx, userId, input.file)
const sequence = await nextSequence(tx, conversationId) const sequence = await nextSequence(tx, conversationId)
@@ -204,7 +224,9 @@ export function conversations(db: Database, userId: string) {
conversationId: string, conversationId: string,
params: ListConversationEntriesParams = {}, params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> { ): Promise<ConversationEntryRow[]> {
await requireConversation(db, userId, conversationId) if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
if (params.visibility) { if (params.visibility) {
return db return db
@@ -226,25 +248,27 @@ export function conversations(db: Database, userId: string) {
.orderBy(asc(conversationEntries.sequence)) .orderBy(asc(conversationEntries.sequence))
}, },
} }
return storage
} }
function payloadForKind( function payloadForKind(
kind: ConversationEntryKindType, kind: ConversationEntryKind,
payload: AppendConversationEntryInput["payload"], payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload { ): ConversationEntryPayload {
switch (kind) { switch (kind) {
case ConversationEntryKind.UserMessage: case ConversationEntryKind.UserMessage:
return UserMessagePayloadSchema.assert(payload) return UserMessagePayload.assert(payload)
case ConversationEntryKind.AssistantMessage: case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayloadSchema.assert(payload) return AssistantMessagePayload.assert(payload)
case ConversationEntryKind.Attachment: case ConversationEntryKind.Attachment:
return AttachmentPayloadSchema.assert(payload) return AttachmentPayload.assert(payload)
case ConversationEntryKind.ContextSummary: case ConversationEntryKind.ContextSummary:
return ContextSummaryPayloadSchema.assert(payload) return ContextSummaryPayload.assert(payload)
case ConversationEntryKind.ToolCall: case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult: case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote: case ConversationEntryKind.SystemNote:
return GenericObjectPayloadSchema.assert(payload) return GenericObjectPayload.assert(payload)
} }
} }
@@ -259,25 +283,11 @@ async function requireUserForUpdate(db: Database, userId: string): Promise<void>
requireRow(rows, `User not found: ${userId}`) requireRow(rows, `User not found: ${userId}`)
} }
async function requireConversation( async function findConversationForUpdate(
db: Database, db: Database,
userId: string, userId: string,
conversationId: string, conversationId: string,
): Promise<ConversationRow> { ): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
return requireRow(rows, `Conversation not found: ${conversationId}`)
}
async function requireConversationForUpdate(
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow> {
const rows = await db const rows = await db
.select() .select()
.from(conversationsTable) .from(conversationsTable)
@@ -285,7 +295,7 @@ async function requireConversationForUpdate(
.limit(1) .limit(1)
.for("update") .for("update")
return requireRow(rows, `Conversation not found: ${conversationId}`) return rows[0] ?? null
} }
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> { async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {
@@ -364,9 +374,7 @@ function requireRow<T>(rows: T[], message = "Expected database row"): T {
return row return row
} }
function defaultVisibilityForKind( function defaultVisibilityForKind(kind: ConversationEntryKind): ConversationEntryVisibility {
kind: ConversationEntryKindType,
): ConversationEntryVisibilityType {
switch (kind) { switch (kind) {
case ConversationEntryKind.UserMessage: case ConversationEntryKind.UserMessage:
case ConversationEntryKind.AssistantMessage: 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 { sql } from "drizzle-orm"
import { import {
boolean, boolean,
@@ -13,14 +20,6 @@ import {
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import {
ConversationEntryVisibility,
type ConversationEntryKind,
type ConversationEntryMetadata,
type ConversationEntryPayload,
type ConversationEntryVisibility as ConversationEntryVisibilityType,
} from "../conversations/types.ts"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Better Auth core tables // Better Auth core tables
// Re-exported from CLI-generated schema. // Re-exported from CLI-generated schema.

View File

@@ -11,6 +11,7 @@ import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts" import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts" import { createRequireSession } from "./auth/session-middleware.ts"
import { CalDavSourceProvider } from "./caldav/provider.ts" import { CalDavSourceProvider } from "./caldav/provider.ts"
import { registerConversationsHttpHandlers } from "./conversations/http.ts"
import { createDatabase } from "./db/index.ts" import { createDatabase } from "./db/index.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts" import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
@@ -129,6 +130,7 @@ function main() {
sessionManager, sessionManager,
authSessionMiddleware, authSessionMiddleware,
}) })
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
if (isDebugMode) { if (isDebugMode) {
registerDebugAgentHttpHandlers(app, { registerDebugAgentHttpHandlers(app, {
authSessionMiddleware, authSessionMiddleware,

View File

@@ -1,5 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location" import { LocationSource } from "@freya/source-location"
import { WeatherSource } from "@freya/source-weatherkit" import { WeatherSource } from "@freya/source-weatherkit"
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
@@ -9,7 +10,6 @@ import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { CredentialEncryptor } from "../lib/crypto.ts" import { CredentialEncryptor } from "../lib/crypto.ts"
import { import {
CredentialStorageUnavailableError, CredentialStorageUnavailableError,

View File

@@ -1,5 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { LocationSource } from "@freya/source-location" import { LocationSource } from "@freya/source-location"
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
@@ -9,7 +10,6 @@ import type {
} from "../agent/conversation-recording-query-agent.ts" } from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts" import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource { function createStubSource(id: string, items: FeedItem[] = []): FeedSource {

View File

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

View File

@@ -10,8 +10,8 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"build:ios": "eas build --profile development --platform ios --non-interactive", "build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive",
"build:ios-simulator": "eas build --profile development-simulator --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" "debugger": "bun run scripts/open-debugger.ts"
}, },
"dependencies": { "dependencies": {
@@ -19,42 +19,38 @@
"@expo-google-fonts/source-serif-4": "^0.4.1", "@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0", "@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33", "expo": "^56.0.0",
"expo-constants": "~18.0.13", "expo-constants": "~56.0.18",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~56.0.20",
"expo-font": "~14.0.11", "expo-font": "~56.0.7",
"expo-haptics": "~15.0.8", "expo-haptics": "~56.0.3",
"expo-image": "~3.0.11", "expo-image": "~56.0.11",
"expo-linking": "~8.0.11", "expo-linking": "~56.0.14",
"expo-location": "~19.0.8", "expo-location": "~56.0.18",
"expo-router": "~6.0.23", "expo-router": "~56.2.11",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~56.0.10",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~56.0.4",
"expo-symbols": "~1.0.8", "expo-symbols": "~56.0.6",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~56.0.5",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~56.0.5",
"react": "19.1.0", "react": "19.2.3",
"react-dom": "19.1.0", "react-dom": "19.2.3",
"react-native": "0.81.5", "react-native": "0.85.3",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.16.0", "react-native-screens": "4.25.2",
"react-native-svg": "15.12.1", "react-native-svg": "15.15.4",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.8.3",
"twrnc": "^4.16.0", "twrnc": "^4.16.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.2.10",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~56.0.4",
"typescript": "^6" "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_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0" const PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0"
const METRO_HOST = process.env.METRO_HOST || "localhost"
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10) const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://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 { function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers) const result = new Headers(headers)
result.delete("origin") result.delete("origin")
result.delete("referer") result.delete("referer")
result.set("host", `127.0.0.1:${METRO_PORT}`) result.set("host", `${METRO_HOST}:${METRO_PORT}`)
return result return result
} }
@@ -40,7 +42,7 @@ Bun.serve<WsData>({
// WebSocket upgrade — bridge to Metro's ws endpoint // WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const wsUrl = `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) const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client // Wait for upstream to connect before upgrading the client
@@ -65,12 +67,12 @@ Bun.serve<WsData>({
// HTTP proxy // HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}` const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const body = req.body ? await req.arrayBuffer() : undefined const body = req.body ? await req.arrayBuffer() : undefined
const res = await fetch(upstream, { const res = await fetchUpstream(upstream, req.method, forwardHeaders(req.headers), body)
method: req.method, if (res == null) {
headers: forwardHeaders(req.headers), return new Response(`Metro is not reachable on ${METRO_HOST}. Restart the Expo dev server.`, {
body, status: 502,
redirect: "manual",
}) })
}
return new Response(res.body, { return new Response(res.body, {
status: res.status, status: res.status,
@@ -121,9 +123,7 @@ async function printDebuggerUrl() {
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend) const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return if (!target) return
const wsPath = target.webSocketDebuggerUrl const wsPath = getProxyWebSocketPath(target.webSocketDebuggerUrl)
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log( console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`, `\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
@@ -131,9 +131,28 @@ async function printDebuggerUrl() {
} }
console.log( 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 { function isDebugTarget(value: unknown): value is DebugTarget {
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
@@ -149,6 +168,11 @@ function isDebugTarget(value: unknown): value is DebugTarget {
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean" return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
} }
function getProxyWebSocketPath(webSocketDebuggerUrl: string) {
const url = new URL(webSocketDebuggerUrl)
return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}`
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import Feather from "@expo/vector-icons/Feather" import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, View } from "react-native" import { type PressableProps, Pressable, type StyleProp, View, type ViewStyle } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text" 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")} /> 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 label: string
leadingIcon?: React.ReactNode leadingIcon?: React.ReactNode
style?: StyleProp<ViewStyle>
trailingIcon?: React.ReactNode trailingIcon?: React.ReactNode
} }

View File

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

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

View File

@@ -45,7 +45,7 @@
# node_modules is content-addressed. If bun.lock or package manifests # node_modules is content-addressed. If bun.lock or package manifests
# change, Nix will report the new hash to put here. # change, Nix will report the new hash to put here.
nodeModulesHashes = { nodeModulesHashes = {
x86_64-linux = "sha256-apVZaFGf9OKpil1WdcQ1CJODsIdjLWlBBZErHg5mjZA="; x86_64-linux = "sha256-8uhlaQAFfCgGdUlrz8sqhtIkC/WfdasbTCi3p/NkU/w=";
}; };
checkSystems = lib.attrNames nodeModulesHashes; checkSystems = lib.attrNames nodeModulesHashes;
@@ -53,7 +53,9 @@
# so source-only edits do not force Bun to reinstall. # so source-only edits do not force Bun to reinstall.
dependencySource = lib.fileset.toSource { dependencySource = lib.fileset.toSource {
root = ./.; root = ./.;
fileset = lib.fileset.fileFilter (file: file.name == "bun.lock" || file.name == "package.json") ./.; 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:.`. # Checks run against a clean source tree, even when using `path:.`.
@@ -243,7 +245,9 @@
bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts); bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts);
commonPackages = with pkgs; [ commonPackages = with pkgs; [
bun bun
eas-cli
git git
gh
gnumake gnumake
nixfmt nixfmt
nodejs nodejs

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { type } from "arktype" import { type } from "arktype"
/** Entry kinds supported by the persisted conversation timeline. */
export const ConversationEntryKind = { export const ConversationEntryKind = {
UserMessage: "user_message", UserMessage: "user_message",
AssistantMessage: "assistant_message", AssistantMessage: "assistant_message",
@@ -10,17 +11,21 @@ export const ConversationEntryKind = {
SystemNote: "system_note", SystemNote: "system_note",
} as const } as const
/** Discriminator for the payload shape and handling of a conversation entry. */
export type ConversationEntryKind = export type ConversationEntryKind =
(typeof ConversationEntryKind)[keyof typeof ConversationEntryKind] (typeof ConversationEntryKind)[keyof typeof ConversationEntryKind]
/** Visibility scopes supported by stored conversation entries. */
export const ConversationEntryVisibility = { export const ConversationEntryVisibility = {
UserVisible: "user_visible", UserVisible: "user_visible",
Internal: "internal", Internal: "internal",
} as const } as const
/** Indicates whether a conversation entry should be exposed to the user. */
export type ConversationEntryVisibility = export type ConversationEntryVisibility =
(typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility] (typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility]
/** Attachment media categories accepted by conversation entries. */
export const AttachmentType = { export const AttachmentType = {
Image: "image", Image: "image",
Audio: "audio", Audio: "audio",
@@ -29,57 +34,64 @@ export const AttachmentType = {
Other: "other", Other: "other",
} as const } as const
/** File or media category associated with an attachment payload. */
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType] export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
export const ConversationEntryKindInput = type.enumerated(...Object.values(ConversationEntryKind)) /** Plain text content part for a message. */
export const ConversationEntryVisibilityInput = type.enumerated( export const TextMessagePart = type({
...Object.values(ConversationEntryVisibility),
)
export const AttachmentTypeInput = type.enumerated(...Object.values(AttachmentType))
const TextMessagePart = type({
"+": "reject", "+": "reject",
type: "'text'", type: "'text'",
text: "string", text: "string",
}) })
const JsonMessagePart = type({ /** Structured JSON content part for a message. */
export const JsonMessagePart = type({
"+": "reject", "+": "reject",
type: "'json'", type: "'json'",
value: "unknown", value: "unknown",
}) })
/** Content part variants supported by user and assistant messages. */
export const MessagePart = type.or(TextMessagePart, JsonMessagePart) export const MessagePart = type.or(TextMessagePart, JsonMessagePart)
/** A structured content part inside a user or assistant message payload. */
export type MessagePart = typeof MessagePart.infer export type MessagePart = typeof MessagePart.infer
/** User-authored message entry payload. */
export const UserMessagePayload = type({ export const UserMessagePayload = type({
"+": "reject", "+": "reject",
role: "'user'", role: "'user'",
parts: MessagePart.array().atLeastLength(1), parts: MessagePart.array().atLeastLength(1),
}) })
/** Payload stored for a conversation entry containing a user message. */
export type UserMessagePayload = typeof UserMessagePayload.infer export type UserMessagePayload = typeof UserMessagePayload.infer
/** Assistant-authored message entry payload. */
export const AssistantMessagePayload = type({ export const AssistantMessagePayload = type({
"+": "reject", "+": "reject",
role: "'assistant'", role: "'assistant'",
parts: MessagePart.array().atLeastLength(1), parts: MessagePart.array().atLeastLength(1),
}) })
/** Payload stored for a conversation entry containing an assistant message. */
export type AssistantMessagePayload = typeof AssistantMessagePayload.infer export type AssistantMessagePayload = typeof AssistantMessagePayload.infer
/** Attachment entry payload. */
export const AttachmentPayload = type({ export const AttachmentPayload = type({
"+": "reject", "+": "reject",
role: type.enumerated("user", "assistant"), role: type.enumerated("user", "assistant"),
name: "string", name: "string",
mimeType: "string", mimeType: "string",
attachmentType: AttachmentTypeInput, attachmentType: type.enumerated(...Object.values(AttachmentType)),
"caption?": "string", "caption?": "string",
}) })
/** Payload stored for a conversation entry that references an uploaded file. */
export type AttachmentPayload = typeof AttachmentPayload.infer export type AttachmentPayload = typeof AttachmentPayload.infer
const ContextSummary = type({ /** Durable facts extracted from compacted conversation history. */
export const ContextSummary = type({
"+": "reject", "+": "reject",
"userIntent?": "string", "userIntent?": "string",
durableFacts: type.string.array(), durableFacts: type.string.array(),
@@ -89,6 +101,10 @@ const ContextSummary = type({
importantDetails: 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({ export const ContextSummaryPayload = type({
"+": "reject", "+": "reject",
covers: type({ covers: type({
@@ -101,8 +117,10 @@ export const ContextSummaryPayload = type({
"sourceEntryIds?": type.string.array(), "sourceEntryIds?": type.string.array(),
}) })
/** Payload describing a compaction summary and the sequence range it covers. */
export type ContextSummaryPayload = typeof ContextSummaryPayload.infer export type ContextSummaryPayload = typeof ContextSummaryPayload.infer
/** Model invocation metadata recorded on generated entries. */
export const ModelRunMetadata = type({ export const ModelRunMetadata = type({
"+": "reject", "+": "reject",
route: "string", route: "string",
@@ -116,18 +134,25 @@ export const ModelRunMetadata = type({
"providerRequestId?": "string", "providerRequestId?": "string",
}) })
/** Metadata describing the model run that produced a conversation entry. */
export type ModelRunMetadata = typeof ModelRunMetadata.infer export type ModelRunMetadata = typeof ModelRunMetadata.infer
/** Arbitrary metadata stored alongside conversation entries. */
export const ConversationEntryMetadata = type({ export const ConversationEntryMetadata = type({
"modelRun?": ModelRunMetadata, "modelRun?": ModelRunMetadata,
"[string]": "unknown", "[string]": "unknown",
}) })
/** Metadata bag attached to a conversation entry. */
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer
/** Generic object payload used by operational entries. */
export const GenericObjectPayload = type("Record<string, unknown>") export const GenericObjectPayload = type("Record<string, unknown>")
/** Fallback payload shape for tool calls, tool results, and system notes. */
export type GenericObjectPayload = typeof GenericObjectPayload.infer export type GenericObjectPayload = typeof GenericObjectPayload.infer
/** Union of payload shapes that can be stored on a conversation entry. */
export type ConversationEntryPayload = export type ConversationEntryPayload =
| UserMessagePayload | UserMessagePayload
| AssistantMessagePayload | AssistantMessagePayload

View File

@@ -6,6 +6,25 @@ export { Context, contextKey, serializeKey } from "./context"
export type { ActionDefinition } from "./action" export type { ActionDefinition } from "./action"
export { UnknownActionError } 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 // Feed
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed" export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
export { TimeRelevance } from "./feed" export { TimeRelevance } from "./feed"