2026-06-17 23:35:00 +01:00
|
|
|
import type {
|
|
|
|
|
AgentClientApi,
|
|
|
|
|
AgentEvent,
|
|
|
|
|
AgentServerApi,
|
|
|
|
|
SendMessageResult,
|
|
|
|
|
} from "@freya/agent-protocol"
|
|
|
|
|
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
|
|
|
|
|
|
|
|
|
|
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
|
|
|
|
|
|
2026-06-14 16:06:31 +01:00
|
|
|
type JsonObject = Record<string, unknown>
|
|
|
|
|
|
|
|
|
|
interface AuthUser {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
email: string
|
|
|
|
|
image: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AuthSession {
|
|
|
|
|
user: AuthUser
|
|
|
|
|
session: {
|
|
|
|
|
id: string
|
|
|
|
|
token: string
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface QueryToolDefinition {
|
|
|
|
|
name: string
|
|
|
|
|
label: string
|
|
|
|
|
description: string
|
|
|
|
|
parameters: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface QueryToolsResponse {
|
|
|
|
|
tools: QueryToolDefinition[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ResultResponse {
|
|
|
|
|
result: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SourceActionsResponse {
|
|
|
|
|
actions: Record<string, { id: string; description?: string }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RequestOptions {
|
|
|
|
|
method?: "GET" | "POST"
|
|
|
|
|
body?: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class CookieJar {
|
|
|
|
|
private readonly cookies = new Map<string, string>()
|
|
|
|
|
|
|
|
|
|
apply(response: Response): void {
|
|
|
|
|
for (const header of readSetCookieHeaders(response.headers)) {
|
|
|
|
|
const cookie = parseCookie(header)
|
|
|
|
|
if (!cookie) continue
|
|
|
|
|
this.cookies.set(cookie.name, cookie.value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header(): string | undefined {
|
|
|
|
|
if (this.cookies.size === 0) return undefined
|
|
|
|
|
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
class AgentWebSocketSession implements AgentClientApi {
|
|
|
|
|
private readonly channel: WebSocketJrpcChannel
|
|
|
|
|
private readonly client: JsonRpcClient<AgentServerApi>
|
|
|
|
|
private readonly server: JsonRpcServer<AgentClientApi>
|
|
|
|
|
private conversationId: string | undefined
|
|
|
|
|
private responseHadText = false
|
|
|
|
|
|
|
|
|
|
private constructor(channel: WebSocketJrpcChannel) {
|
|
|
|
|
this.channel = channel
|
|
|
|
|
this.client = new JsonRpcClient<AgentServerApi>(channel)
|
|
|
|
|
this.server = new JsonRpcServer<AgentClientApi>(
|
|
|
|
|
{
|
|
|
|
|
notify: this.notify.bind(this),
|
|
|
|
|
},
|
|
|
|
|
channel,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async connect(backendUrl: string, cookies: CookieJar): Promise<AgentWebSocketSession> {
|
|
|
|
|
const channel = new WebSocketJrpcChannel(agentWebSocketUrl(backendUrl), cookies.header())
|
|
|
|
|
const session = new AgentWebSocketSession(channel)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await channel.waitUntilOpen()
|
|
|
|
|
void session.server.start().catch((err: unknown) => {
|
|
|
|
|
if (!channel.isClosed()) {
|
|
|
|
|
console.error(`\nWebSocket JSON-RPC failed: ${formatError(err)}\n`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
await session.client.call("ping")
|
|
|
|
|
} catch (err) {
|
|
|
|
|
channel.close()
|
|
|
|
|
throw err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return session
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ask(message: string): Promise<void> {
|
|
|
|
|
this.responseHadText = false
|
|
|
|
|
|
|
|
|
|
const result = await this.sendMessage(message)
|
|
|
|
|
if (result.conversationId) {
|
|
|
|
|
this.conversationId = result.conversationId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.responseHadText) {
|
|
|
|
|
console.log(`\nagent> ${result.message || "(no message)"}`)
|
|
|
|
|
}
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notify(event: AgentEvent): void {
|
|
|
|
|
switch (event.type) {
|
|
|
|
|
case "conversation_started":
|
|
|
|
|
this.conversationId = event.conversationId
|
|
|
|
|
break
|
|
|
|
|
case "message_created":
|
|
|
|
|
this.printMessage(event.text)
|
|
|
|
|
break
|
|
|
|
|
case "tool_started":
|
|
|
|
|
console.log(`\ntool> ${event.toolName} started`)
|
|
|
|
|
break
|
|
|
|
|
case "tool_finished":
|
|
|
|
|
console.log(`tool> ${event.toolName} ${event.ok ? "finished" : "failed"}`)
|
|
|
|
|
break
|
|
|
|
|
case "message_finished":
|
|
|
|
|
break
|
|
|
|
|
case "message_failed":
|
|
|
|
|
console.log(`\nagent! ${event.error}`)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describeConversation(): string {
|
|
|
|
|
return this.conversationId ? `Conversation: ${this.conversationId}` : "No conversation yet."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(): void {
|
|
|
|
|
this.channel.close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async sendMessage(message: string): Promise<SendMessageResult> {
|
|
|
|
|
return this.client.call("sendMessage", message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private printMessage(text: string): void {
|
|
|
|
|
if (text === "") return
|
|
|
|
|
|
|
|
|
|
console.log(`\nagent> ${text}`)
|
|
|
|
|
this.responseHadText = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class WebSocketJrpcChannel implements JrpcChannel {
|
|
|
|
|
private readonly ws: WebSocket
|
|
|
|
|
private readonly opened: Promise<void>
|
|
|
|
|
private closed = false
|
|
|
|
|
private openedOnce = false
|
|
|
|
|
private queue: JrpcMessage[] = []
|
|
|
|
|
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
|
|
|
|
|
|
|
|
|
|
constructor(url: string, cookieHeader?: string) {
|
|
|
|
|
this.ws = new WebSocket(url, createWebSocketOptions(cookieHeader))
|
|
|
|
|
this.opened = new Promise((resolve, reject) => {
|
|
|
|
|
this.ws.onopen = () => {
|
|
|
|
|
this.openedOnce = true
|
|
|
|
|
resolve()
|
|
|
|
|
}
|
|
|
|
|
this.ws.onerror = () => {
|
|
|
|
|
if (!this.openedOnce) {
|
|
|
|
|
reject(new Error(`Could not connect to ${url}`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.ws.onclose = (event) => {
|
|
|
|
|
if (!this.openedOnce) {
|
|
|
|
|
reject(new Error(formatWebSocketClose(url, event)))
|
|
|
|
|
}
|
|
|
|
|
this.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
this.ws.onmessage = (event) => {
|
|
|
|
|
this.receive(event.data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
waitUntilOpen(): Promise<void> {
|
|
|
|
|
return this.opened
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isClosed(): boolean {
|
|
|
|
|
return this.closed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async send(msg: JsonRpcMessage): Promise<void> {
|
|
|
|
|
await this.opened
|
|
|
|
|
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
throw new Error("JSON-RPC WebSocket channel is closed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.send(JSON.stringify(msg))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async next(): Promise<IteratorResult<JrpcMessage, void>> {
|
|
|
|
|
const msg = this.queue.shift()
|
|
|
|
|
if (msg) {
|
|
|
|
|
return { done: false, value: msg }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.closed) {
|
|
|
|
|
return { done: true, value: undefined }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
this.waiters.push(resolve)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async return(): Promise<IteratorResult<JrpcMessage, void>> {
|
|
|
|
|
this.close()
|
|
|
|
|
return { done: true, value: undefined }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
|
|
|
|
|
this.close()
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async [Symbol.asyncDispose](): Promise<void> {
|
|
|
|
|
await this.return()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(): void {
|
|
|
|
|
if (this.closed) return
|
|
|
|
|
|
|
|
|
|
this.closed = true
|
|
|
|
|
for (const resolve of this.waiters.splice(0)) {
|
|
|
|
|
resolve({ done: true, value: undefined })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
this.ws.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
|
|
|
|
|
return this
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private receive(message: unknown): void {
|
|
|
|
|
const parsed = parseJrpcMessage(message)
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
this.ws.close(1003, "Invalid JSON-RPC message")
|
|
|
|
|
this.close()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.push(parsed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private push(msg: JrpcMessage): void {
|
|
|
|
|
if (this.closed) return
|
|
|
|
|
|
|
|
|
|
const resolve = this.waiters.shift()
|
|
|
|
|
if (resolve) {
|
|
|
|
|
resolve({ done: false, value: msg })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.queue.push(msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 16:06:31 +01:00
|
|
|
async function main(): Promise<void> {
|
|
|
|
|
if (wantsHelp()) {
|
|
|
|
|
printUsage()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
printIntro()
|
|
|
|
|
|
|
|
|
|
const backendUrl = askRequired(
|
|
|
|
|
"Backend URL",
|
|
|
|
|
Bun.env.FREYA_BACKEND_URL ?? "http://localhost:3000",
|
|
|
|
|
normalizeBackendUrl,
|
|
|
|
|
)
|
|
|
|
|
const email = askRequired("Email", Bun.env.FREYA_EMAIL)
|
|
|
|
|
const password = askRequired("Password", Bun.env.FREYA_PASSWORD, undefined, true)
|
|
|
|
|
|
|
|
|
|
const cookies = new CookieJar()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const session = await signIn(backendUrl, cookies, email, password)
|
|
|
|
|
console.log(`\nSigned in as ${session.user.email}`)
|
|
|
|
|
await runChatLoop(backendUrl, cookies, session)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`\n${formatError(err)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function signIn(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
email: string,
|
|
|
|
|
password: string,
|
|
|
|
|
): Promise<AuthSession> {
|
|
|
|
|
await requestJson(backendUrl, cookies, "/api/auth/sign-in/email", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { email, password },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = await requestJson(backendUrl, cookies, "/api/auth/get-session")
|
|
|
|
|
if (!isAuthSession(data)) {
|
|
|
|
|
throw new Error("Sign-in succeeded, but no session was returned")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runChatLoop(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
session: AuthSession,
|
|
|
|
|
): Promise<void> {
|
2026-06-17 23:35:00 +01:00
|
|
|
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
|
|
|
|
|
console.log("Connected to /api/agent/ws")
|
2026-06-14 16:06:31 +01:00
|
|
|
printHelp()
|
|
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
try {
|
|
|
|
|
for (;;) {
|
|
|
|
|
const input = askOptional("you> ")?.trim()
|
|
|
|
|
if (!input) continue
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input === "/quit" || input === "/exit") {
|
|
|
|
|
console.log("Bye.")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input === "/help") {
|
|
|
|
|
printHelp()
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input === "/session") {
|
|
|
|
|
console.log(`${session.user.name || session.user.email} (${session.user.id})`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input === "/conversation") {
|
|
|
|
|
console.log(agent.describeConversation())
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input === "/tools") {
|
|
|
|
|
await runCliCommand(() => listQueryTools(backendUrl, cookies))
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input.startsWith("/tool ")) {
|
|
|
|
|
await runCliCommand(() =>
|
|
|
|
|
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input.startsWith("/actions ")) {
|
|
|
|
|
await runCliCommand(() =>
|
|
|
|
|
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
if (input.startsWith("/action ")) {
|
|
|
|
|
await runCliCommand(() =>
|
|
|
|
|
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-06-14 16:06:31 +01:00
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
try {
|
|
|
|
|
await agent.ask(input)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`\n${formatError(err)}\n`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
agent.close()
|
2026-06-14 16:06:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runCliCommand(command: () => Promise<void>): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await command()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`\n${formatError(err)}\n`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listQueryTools(backendUrl: string, cookies: CookieJar): Promise<void> {
|
|
|
|
|
const data = await requestJson(backendUrl, cookies, "/api/agent/tools")
|
|
|
|
|
if (!isQueryToolsResponse(data)) {
|
|
|
|
|
throw new Error("Agent tools returned an unexpected response shape")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("")
|
|
|
|
|
for (const tool of data.tools) {
|
|
|
|
|
console.log(`${tool.name} - ${tool.label}`)
|
|
|
|
|
console.log(` ${tool.description}`)
|
|
|
|
|
console.log(` params=${formatJson(tool.parameters)}`)
|
|
|
|
|
}
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executeQueryTool(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
command: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const parsed = splitFirst(command.trim())
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
throw new Error("Usage: /tool <name> <json-params>; example: /tool freya_list_context {}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const params = parseJsonArgument(parsed.rest, {})
|
|
|
|
|
const data = await requestJson(backendUrl, cookies, `/api/agent/tools/${urlPart(parsed.head)}`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: params,
|
|
|
|
|
})
|
|
|
|
|
if (!isResultResponse(data)) {
|
|
|
|
|
throw new Error("Tool execution returned an unexpected response shape")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`\ntool ${parsed.head}>`)
|
|
|
|
|
console.log(formatJson(data.result))
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listSourceActions(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
command: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const sourceId = command.trim()
|
|
|
|
|
if (!sourceId) {
|
|
|
|
|
throw new Error("Usage: /actions <source-id>")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await requestJson(backendUrl, cookies, `/api/sources/${urlPart(sourceId)}/actions`)
|
|
|
|
|
if (!isSourceActionsResponse(data)) {
|
|
|
|
|
throw new Error("Source actions returned an unexpected response shape")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actions = Object.entries(data.actions)
|
|
|
|
|
console.log("")
|
|
|
|
|
if (actions.length === 0) {
|
|
|
|
|
console.log(`No actions for ${sourceId}.`)
|
|
|
|
|
} else {
|
|
|
|
|
for (const [key, action] of actions) {
|
|
|
|
|
console.log(`${sourceId}/${key}`)
|
|
|
|
|
console.log(` id=${action.id}`)
|
|
|
|
|
if (action.description) console.log(` ${action.description}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executeSourceAction(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
command: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const source = splitFirst(command.trim())
|
|
|
|
|
if (!source) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const action = splitFirst(source.rest)
|
|
|
|
|
if (!action) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const params = parseJsonArgument(action.rest, {})
|
|
|
|
|
const data = await requestJson(
|
|
|
|
|
backendUrl,
|
|
|
|
|
cookies,
|
|
|
|
|
`/api/sources/${urlPart(source.head)}/actions/${urlPart(action.head)}`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: params,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if (!isResultResponse(data)) {
|
|
|
|
|
throw new Error("Source action returned an unexpected response shape")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`\naction ${source.head}/${action.head}>`)
|
|
|
|
|
console.log(formatJson(data.result))
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestJson(
|
|
|
|
|
backendUrl: string,
|
|
|
|
|
cookies: CookieJar,
|
|
|
|
|
path: string,
|
|
|
|
|
options: RequestOptions = {},
|
|
|
|
|
): Promise<unknown> {
|
|
|
|
|
const headers = new Headers()
|
|
|
|
|
headers.set("Accept", "application/json")
|
|
|
|
|
|
|
|
|
|
const cookieHeader = cookies.header()
|
|
|
|
|
if (cookieHeader) headers.set("Cookie", cookieHeader)
|
|
|
|
|
|
|
|
|
|
let body: string | undefined
|
|
|
|
|
if (options.body !== undefined) {
|
|
|
|
|
headers.set("Content-Type", "application/json")
|
|
|
|
|
body = JSON.stringify(options.body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${backendUrl}${path}`, {
|
|
|
|
|
method: options.method ?? "GET",
|
|
|
|
|
headers,
|
|
|
|
|
body,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cookies.apply(response)
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readResponseError(response, path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printIntro(): void {
|
|
|
|
|
console.log("FREYA agent test CLI")
|
2026-06-17 23:35:00 +01:00
|
|
|
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n")
|
2026-06-14 16:06:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printUsage(): void {
|
|
|
|
|
console.log("FREYA agent test CLI")
|
|
|
|
|
console.log("")
|
|
|
|
|
console.log("Usage:")
|
|
|
|
|
console.log(" bun run agent-test-cli")
|
|
|
|
|
console.log(
|
|
|
|
|
" FREYA_BACKEND_URL=http://localhost:3000 FREYA_EMAIL=user@example.com FREYA_PASSWORD=secret bun run agent-test-cli",
|
|
|
|
|
)
|
|
|
|
|
console.log("")
|
|
|
|
|
printHelp()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printHelp(): void {
|
|
|
|
|
console.log("\nCommands:")
|
|
|
|
|
console.log(" /tools List agent debug tools")
|
|
|
|
|
console.log(" /tool Execute an agent debug tool with JSON params")
|
|
|
|
|
console.log(" /actions List source actions: /actions <source-id>")
|
|
|
|
|
console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>")
|
2026-06-17 23:35:00 +01:00
|
|
|
console.log(" /conversation Show the current websocket conversation")
|
2026-06-14 16:06:31 +01:00
|
|
|
console.log(" /session Show the signed-in user")
|
|
|
|
|
console.log(" /help Show commands")
|
|
|
|
|
console.log(" /quit Exit\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function askRequired(
|
|
|
|
|
label: string,
|
|
|
|
|
defaultValue?: string,
|
|
|
|
|
transform?: (value: string) => string,
|
|
|
|
|
hidden = false,
|
|
|
|
|
): string {
|
|
|
|
|
if (hidden && defaultValue) {
|
|
|
|
|
const value = defaultValue.trim()
|
|
|
|
|
if (value) return transform ? transform(value) : value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canRetry = canRunStty()
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
const answer = hidden
|
|
|
|
|
? askHidden(label, defaultValue)
|
|
|
|
|
: askOptional(formatPromptLabel(label, defaultValue))
|
|
|
|
|
const value = (answer || defaultValue || "").trim()
|
|
|
|
|
if (!value) {
|
|
|
|
|
if (!canRetry) {
|
|
|
|
|
throw new Error(`${label} is required`)
|
|
|
|
|
}
|
|
|
|
|
console.log(`${label} is required.`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return transform ? transform(value) : value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function askOptional(label: string): string | null {
|
|
|
|
|
return prompt(label)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function askHidden(label: string, defaultValue?: string): string | null {
|
|
|
|
|
const shouldHide = !defaultValue && canRunStty()
|
|
|
|
|
if (!shouldHide) return askOptional(formatPromptLabel(label, defaultValue))
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
Bun.spawnSync(["stty", "-echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
|
|
|
|
|
return askOptional(`${label}: `)
|
|
|
|
|
} finally {
|
|
|
|
|
Bun.spawnSync(["stty", "echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
|
|
|
|
|
console.log("")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wantsHelp(): boolean {
|
|
|
|
|
return Bun.argv.some((arg) => arg === "--help" || arg === "-h")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeBackendUrl(value: string): string {
|
|
|
|
|
const withProtocol = /^[a-z]+:\/\//i.test(value) ? value : `http://${value}`
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(withProtocol)
|
|
|
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
|
|
|
throw new Error("Backend URL must use http or https")
|
|
|
|
|
}
|
|
|
|
|
return url.toString().replace(/\/+$/, "")
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error(`Invalid backend URL: ${value}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
function agentWebSocketUrl(backendUrl: string): string {
|
|
|
|
|
const url = new URL(backendUrl)
|
|
|
|
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
|
|
|
url.pathname = "/api/agent/ws"
|
|
|
|
|
url.search = ""
|
|
|
|
|
url.hash = ""
|
|
|
|
|
return url.toString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createWebSocketOptions(cookieHeader?: string): Bun.WebSocketOptions | undefined {
|
|
|
|
|
if (!cookieHeader) return undefined
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
headers: {
|
|
|
|
|
Cookie: cookieHeader,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatWebSocketClose(
|
|
|
|
|
url: string,
|
|
|
|
|
event: { code: number; reason: string; wasClean: boolean },
|
|
|
|
|
): string {
|
|
|
|
|
const reason = event.reason ? `: ${event.reason}` : ""
|
|
|
|
|
return `Could not connect to ${url} (${event.code}${reason})`
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 16:06:31 +01:00
|
|
|
function formatPromptLabel(label: string, defaultValue?: string): string {
|
|
|
|
|
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitFirst(value: string): { head: string; rest: string } | null {
|
|
|
|
|
const trimmed = value.trim()
|
|
|
|
|
if (!trimmed) return null
|
|
|
|
|
|
|
|
|
|
const match = /\s/.exec(trimmed)
|
|
|
|
|
if (!match) {
|
|
|
|
|
return { head: trimmed, rest: "" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const head = trimmed.slice(0, match.index)
|
|
|
|
|
const rest = trimmed.slice(match.index).trim()
|
|
|
|
|
return { head, rest }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseJsonArgument(value: string, fallback: unknown): unknown {
|
|
|
|
|
if (!value.trim()) return fallback
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new Error(`Invalid JSON params: ${formatError(err)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatJson(value: unknown): string {
|
|
|
|
|
const serialized = JSON.stringify(value, null, 2)
|
|
|
|
|
return serialized ?? "undefined"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function urlPart(value: string): string {
|
|
|
|
|
return encodeURIComponent(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canRunStty(): boolean {
|
|
|
|
|
const result = Bun.spawnSync(["stty", "-g"], { stdin: "inherit", stdout: "pipe", stderr: "pipe" })
|
|
|
|
|
return result.exitCode === 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readSetCookieHeaders(headers: Headers): string[] {
|
|
|
|
|
const setCookies = headers.getSetCookie()
|
|
|
|
|
if (setCookies && setCookies.length > 0) return setCookies
|
|
|
|
|
|
|
|
|
|
const header = headers.get("set-cookie")
|
|
|
|
|
if (!header) return []
|
|
|
|
|
|
|
|
|
|
return splitSetCookieHeader(header)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCookie(header: string): { name: string; value: string } | null {
|
|
|
|
|
const [cookiePair] = header.split(";")
|
|
|
|
|
if (!cookiePair) return null
|
|
|
|
|
|
|
|
|
|
const index = cookiePair.indexOf("=")
|
|
|
|
|
if (index <= 0) return null
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
name: cookiePair.slice(0, index).trim(),
|
|
|
|
|
value: cookiePair.slice(index + 1).trim(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitSetCookieHeader(header: string): string[] {
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
let start = 0
|
|
|
|
|
let inExpires = false
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < header.length; index += 1) {
|
|
|
|
|
const char = header[index]
|
|
|
|
|
const remainder = header.slice(index).toLowerCase()
|
|
|
|
|
|
|
|
|
|
if (remainder.startsWith("expires=")) {
|
|
|
|
|
inExpires = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (inExpires && char === ";") {
|
|
|
|
|
inExpires = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (char === "," && !inExpires) {
|
|
|
|
|
parts.push(header.slice(start, index).trim())
|
|
|
|
|
start = index + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts.push(header.slice(start).trim())
|
|
|
|
|
return parts.filter(Boolean)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
function parseJrpcMessage(message: unknown): JrpcMessage | null {
|
|
|
|
|
const text = webSocketMessageText(message)
|
|
|
|
|
if (!text) return null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const value: unknown = JSON.parse(text)
|
|
|
|
|
return isJrpcMessage(value) ? value : null
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function webSocketMessageText(message: unknown): string | null {
|
|
|
|
|
if (typeof message === "string") return message
|
|
|
|
|
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8")
|
|
|
|
|
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 16:06:31 +01:00
|
|
|
async function readResponseError(response: Response, path: string): Promise<string> {
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
if (response.status === 404 && path === "/api/agent") {
|
|
|
|
|
return "Backend does not expose /api/agent. Restart the WIP backend on port 3000 or check FREYA_BACKEND_URL."
|
|
|
|
|
}
|
|
|
|
|
if (!text) return `Request failed: ${response.status} ${response.statusText}`
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data: unknown = JSON.parse(text)
|
|
|
|
|
if (isJsonObject(data)) {
|
|
|
|
|
const message = readString(data, "message") ?? readString(data, "error")
|
|
|
|
|
if (message) return message
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return `Request failed: ${response.status} ${response.statusText}: ${text}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `Request failed: ${response.status} ${response.statusText}: ${text}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAuthSession(value: unknown): value is AuthSession {
|
|
|
|
|
if (!isJsonObject(value)) return false
|
|
|
|
|
const user = value.user
|
|
|
|
|
const session = value.session
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
isJsonObject(user) &&
|
|
|
|
|
isJsonObject(session) &&
|
|
|
|
|
typeof user.id === "string" &&
|
|
|
|
|
typeof user.name === "string" &&
|
|
|
|
|
typeof user.email === "string" &&
|
|
|
|
|
(user.image === null || typeof user.image === "string") &&
|
|
|
|
|
typeof session.id === "string" &&
|
|
|
|
|
typeof session.token === "string"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
|
|
|
|
|
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
|
|
|
|
|
return value.tools.every(isQueryToolDefinition)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isQueryToolDefinition(value: unknown): value is QueryToolDefinition {
|
|
|
|
|
return (
|
|
|
|
|
isJsonObject(value) &&
|
|
|
|
|
typeof value.name === "string" &&
|
|
|
|
|
typeof value.label === "string" &&
|
|
|
|
|
typeof value.description === "string" &&
|
|
|
|
|
"parameters" in value
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isResultResponse(value: unknown): value is ResultResponse {
|
|
|
|
|
return isJsonObject(value) && "result" in value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSourceActionsResponse(value: unknown): value is SourceActionsResponse {
|
|
|
|
|
if (!isJsonObject(value) || !isJsonObject(value.actions)) return false
|
|
|
|
|
return Object.values(value.actions).every(isSourceActionDefinition)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSourceActionDefinition(value: unknown): value is { id: string; description?: string } {
|
|
|
|
|
return (
|
|
|
|
|
isJsonObject(value) &&
|
|
|
|
|
typeof value.id === "string" &&
|
|
|
|
|
(value.description === undefined || typeof value.description === "string")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:35:00 +01:00
|
|
|
function isJrpcMessage(value: unknown): value is JrpcMessage {
|
|
|
|
|
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.id !== "number") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ("method" in value) {
|
|
|
|
|
return (
|
|
|
|
|
typeof value.method === "string" &&
|
|
|
|
|
(value.params === undefined || Array.isArray(value.params))
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ("result" in value) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ("error" in value) {
|
|
|
|
|
return isJsonRpcErrorObject(value.error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isJsonRpcErrorObject(value: unknown): boolean {
|
|
|
|
|
return isJsonObject(value) && typeof value.code === "number" && typeof value.message === "string"
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 16:06:31 +01:00
|
|
|
function isJsonObject(value: unknown): value is JsonObject {
|
|
|
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readString(object: JsonObject, key: string): string | undefined {
|
|
|
|
|
const value = object[key]
|
|
|
|
|
return typeof value === "string" ? value : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatError(error: unknown): string {
|
|
|
|
|
return error instanceof Error ? error.message : String(error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await main()
|