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

@@ -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,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 { Hono } from "hono"
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 { 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
},
}),
}))
@@ -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", () => {
beforeEach(() => {
conversationRowsByUser.clear()
conversationEntryRowsByUserAndConversation.clear()
listEntriesCalls.length = 0
})
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 { 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,
@@ -28,6 +43,7 @@ export function registerConversationsHttpHandlers(
})
app.get("/api/conversations", inject, authSessionMiddleware, handleListConversations)
app.get("/api/conversations/:id/entries", inject, authSessionMiddleware, handleListEntries)
}
async function handleListConversations(c: Context<Env>) {
@@ -35,10 +51,54 @@ async function handleListConversations(c: Context<Env>) {
const db = c.get("db")
return c.json({
conversations: (await conversations(db, user.id).listConversations()).map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
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,12 +94,13 @@ 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)
},
@@ -109,6 +113,18 @@ export function conversations(db: Database, userId: string) {
.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)
@@ -127,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) {
@@ -141,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
@@ -168,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)
@@ -204,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
@@ -226,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)
}
}
@@ -259,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)
@@ -285,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> {
@@ -364,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

@@ -11,6 +11,7 @@ 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"
@@ -129,6 +130,7 @@ function main() {
sessionManager,
authSessionMiddleware,
})
registerConversationsHttpHandlers(app, { db, authSessionMiddleware })
if (isDebugMode) {
registerDebugAgentHttpHandlers(app, {
authSessionMiddleware,

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"

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
# change, Nix will report the new hash to put here.
nodeModulesHashes = {
x86_64-linux = "sha256-apVZaFGf9OKpil1WdcQ1CJODsIdjLWlBBZErHg5mjZA=";
x86_64-linux = "sha256-8uhlaQAFfCgGdUlrz8sqhtIkC/WfdasbTCi3p/NkU/w=";
};
checkSystems = lib.attrNames nodeModulesHashes;
@@ -53,7 +53,9 @@
# 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") ./.;
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:.`.
@@ -243,7 +245,9 @@
bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts);
commonPackages = with pkgs; [
bun
eas-cli
git
gh
gnumake
nixfmt
nodejs

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

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

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