refactor: rombak dari monorepo ke project tunggal — flat src/{domain,application,infrastructure,interfaces}, satu package.json tanpa workspaces, tsconfig paths → src/*, update install.sh/Dockerfile/CI
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Data Transfer Objects for the REST API.
|
||||
* Wire format for request/response bodies — independent of domain entities.
|
||||
* Mirrors `apps/interfaces/api/src/dto/*.rs`.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Auth DTOs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
/** Optional display name. */
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface RefreshRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Session DTOs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
title: string;
|
||||
model: string;
|
||||
message_count: number;
|
||||
archived: boolean;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
sessions: SessionResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Conversation DTOs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface AddMessageRequest {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolFunctionResponse {
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
export interface ToolCallResponse {
|
||||
id: string;
|
||||
function: ToolFunctionResponse;
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
role: string;
|
||||
content?: string;
|
||||
tool_calls?: ToolCallResponse[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
session_id: string;
|
||||
messages: MessageResponse[];
|
||||
message_count: number;
|
||||
model: string;
|
||||
system_prompt: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
session_id: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
reply: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Error DTO */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/** Build a standardised error response body. */
|
||||
export function errorBody(message: string, code: number): ErrorResponse {
|
||||
return { message, code };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Typed API error with automatic HTTP response conversion.
|
||||
* Mirrors `apps/interfaces/api/src/error.rs`.
|
||||
*
|
||||
* Each kind maps to a status code:
|
||||
* - BadRequest → 400
|
||||
* - Unauthorized → 401
|
||||
* - NotFound → 404
|
||||
* - Conflict → 409
|
||||
* - TooManyRequests → 429
|
||||
* - Internal → 500
|
||||
* - ChatProxy → 502
|
||||
*/
|
||||
|
||||
export type ApiErrorKind =
|
||||
| "BadRequest"
|
||||
| "Unauthorized"
|
||||
| "NotFound"
|
||||
| "Conflict"
|
||||
| "TooManyRequests"
|
||||
| "Internal"
|
||||
| "ChatProxy";
|
||||
|
||||
export const HTTP_STATUS: Record<ApiErrorKind, number> = {
|
||||
BadRequest: 400,
|
||||
Unauthorized: 401,
|
||||
NotFound: 404,
|
||||
Conflict: 409,
|
||||
TooManyRequests: 429,
|
||||
Internal: 500,
|
||||
ChatProxy: 502,
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public kind: ApiErrorKind, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get status(): number {
|
||||
return HTTP_STATUS[this.kind];
|
||||
}
|
||||
|
||||
static badRequest(msg: string): ApiError {
|
||||
return new ApiError("BadRequest", msg);
|
||||
}
|
||||
static unauthorized(msg: string): ApiError {
|
||||
return new ApiError("Unauthorized", msg);
|
||||
}
|
||||
static notFound(msg: string): ApiError {
|
||||
return new ApiError("NotFound", msg);
|
||||
}
|
||||
static conflict(msg: string): ApiError {
|
||||
return new ApiError("Conflict", msg);
|
||||
}
|
||||
static tooManyRequests(msg: string): ApiError {
|
||||
return new ApiError("TooManyRequests", msg);
|
||||
}
|
||||
static internal(msg: string): ApiError {
|
||||
return new ApiError("Internal", msg);
|
||||
}
|
||||
static chatProxy(msg: string): ApiError {
|
||||
return new ApiError("ChatProxy", msg);
|
||||
}
|
||||
|
||||
/** Whether an internal error's details should be hidden from the client. */
|
||||
get exposeDetails(): boolean {
|
||||
// Internal errors hide details; all others surface the message.
|
||||
return this.kind !== "Internal";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Authentication handlers — login, register, and token refresh.
|
||||
* Mirrors `apps/interfaces/api/src/handlers/auth.rs`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /auth/login — authenticate, return JWT pair
|
||||
* - POST /auth/register — create account, return JWT pair
|
||||
* - POST /auth/refresh — exchange refresh token for a new pair
|
||||
*
|
||||
* All three are rate-limited (20 attempts / 10 min per client).
|
||||
*/
|
||||
import type { ApiState, RateLimiter } from "../state.ts";
|
||||
import { ApiError } from "../error.ts";
|
||||
import { loadUsers, saveUsers } from "../state.ts";
|
||||
import type { AuthResponse, LoginRequest, RegisterRequest, RefreshRequest } from "../dto.ts";
|
||||
|
||||
/** Login/register brute-force protection: 20 attempts per 10-minute window. */
|
||||
const AUTH_RATE_LIMIT_MAX = 20;
|
||||
const AUTH_RATE_LIMIT_WINDOW_SECS = 600;
|
||||
|
||||
/** Extract a coarse client identity (X-Forwarded-For first hop or "unknown"). */
|
||||
function clientId(headers: Headers): string {
|
||||
const xff = headers.get("x-forwarded-for");
|
||||
if (xff) {
|
||||
const first = xff.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Enforce the auth rate limit; throws TooManyRequests when exceeded. */
|
||||
function enforceRateLimit(limiter: RateLimiter, headers: Headers): void {
|
||||
if (!limiter.check(clientId(headers), AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW_SECS)) {
|
||||
throw ApiError.tooManyRequests("Too many requests, try again later");
|
||||
}
|
||||
}
|
||||
|
||||
function requireFields(req: LoginRequest & RegisterRequest, reg: boolean): void {
|
||||
if (req.username === "") {
|
||||
throw ApiError.badRequest("Username is required");
|
||||
}
|
||||
if (req.password === "") {
|
||||
throw ApiError.badRequest("Password is required");
|
||||
}
|
||||
if (reg && req.password.length < 6) {
|
||||
throw ApiError.badRequest("Password must be at least 6 characters");
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue an AuthResponse for the given subject. */
|
||||
function authResponse(state: ApiState, sub: string): AuthResponse {
|
||||
const [access, refresh] = state.token_service.generateTokens(sub);
|
||||
return {
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600, // access token expiry in seconds
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /auth/login — authenticate and issue a JWT pair. */
|
||||
export async function loginHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: LoginRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
requireFields(body, false);
|
||||
|
||||
const users = loadUsers(state.store_base_dir);
|
||||
if (!users) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
const storedHash = users[body.username];
|
||||
if (!storedHash) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
|
||||
const valid = await state.password_service.verify(body.password, storedHash);
|
||||
if (!valid) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
|
||||
return authResponse(state, body.username);
|
||||
}
|
||||
|
||||
/** POST /auth/register — create a new user account and issue a JWT pair. */
|
||||
export async function registerHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: RegisterRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
requireFields(body, true);
|
||||
|
||||
// Hash first (async), then serialize the read-modify-write of users.json.
|
||||
const hash = await state.password_service.hash(body.password);
|
||||
|
||||
const unlock = state.users_lock.lock();
|
||||
try {
|
||||
const users = loadUsers(state.store_base_dir) ?? {};
|
||||
if (body.username in users) {
|
||||
throw ApiError.conflict("Username already exists");
|
||||
}
|
||||
users[body.username] = hash;
|
||||
saveUsers(state.store_base_dir, users);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
|
||||
return authResponse(state, body.username);
|
||||
}
|
||||
|
||||
/** POST /auth/refresh — exchange a refresh token for a fresh pair. */
|
||||
export async function refreshHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: RefreshRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
if (body.refresh_token === "") {
|
||||
throw ApiError.badRequest("Refresh token is required");
|
||||
}
|
||||
|
||||
let sub: string;
|
||||
try {
|
||||
sub = state.token_service.verifyRefreshToken(body.refresh_token);
|
||||
} catch {
|
||||
throw ApiError.unauthorized("Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
return authResponse(state, sub);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* LLM chat completion proxy handler.
|
||||
* Mirrors `apps/interfaces/api/src/handlers/chat.rs`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /chat/completions — proxy a completion to the LLM, persisting history.
|
||||
*/
|
||||
import type { ApiState } from "../state.ts";
|
||||
import { ApiError } from "../error.ts";
|
||||
import { newConversation, Roles, type ChatMessage } from "@zesdex/domain";
|
||||
import type { ChatCompletionResponse } from "../dto.ts";
|
||||
|
||||
/** POST /chat/completions — proxy to LLM provider and persist the conversation. */
|
||||
export async function chatCompletionsHandler(
|
||||
state: ApiState,
|
||||
req: {
|
||||
session_id: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
},
|
||||
): Promise<ChatCompletionResponse> {
|
||||
if (req.session_id === "") throw ApiError.badRequest("session_id is required");
|
||||
if (req.message === "") throw ApiError.badRequest("message is required");
|
||||
|
||||
// Load or create the conversation for this session.
|
||||
let conversation;
|
||||
try {
|
||||
conversation = await state.conversation_service.loadConversation(req.session_id);
|
||||
} catch {
|
||||
conversation = newConversation("", req.session_id);
|
||||
conversation.model = req.model ?? state.llm_client.model;
|
||||
if (req.max_tokens !== undefined) conversation.max_tokens = req.max_tokens;
|
||||
if (req.temperature !== undefined) conversation.temperature = req.temperature;
|
||||
}
|
||||
|
||||
if (req.model !== undefined) conversation.model = req.model;
|
||||
|
||||
const userMsg: ChatMessage = { role: Roles.User, content: req.message };
|
||||
conversation.messages.push(userMsg);
|
||||
|
||||
// Call the LLM (non-streaming), using the conversation's resolved model.
|
||||
let response: ChatMessage;
|
||||
let usage: [number, number] | null = null;
|
||||
try {
|
||||
const result = await state.llm_client.chat(
|
||||
conversation.messages,
|
||||
undefined,
|
||||
conversation.max_tokens,
|
||||
conversation.temperature,
|
||||
);
|
||||
response = result.message;
|
||||
usage = result.usage;
|
||||
} catch (e) {
|
||||
throw ApiError.chatProxy(`LLM request failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const [promptTokens, completionTokens] = usage ?? [0, 0];
|
||||
const replyText = response.content ?? "";
|
||||
|
||||
const assistantMsg: ChatMessage = { role: Roles.Assistant, content: replyText };
|
||||
await state.conversation_service.addMessage(conversation, assistantMsg);
|
||||
|
||||
return { reply: replyText, prompt_tokens: promptTokens, completion_tokens: completionTokens };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Conversation message-history handlers.
|
||||
* Mirrors `apps/interfaces/api/src/handlers/conversations.rs`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /sessions/:id/conversations — fetch conversation
|
||||
* - POST /sessions/:id/conversations — append a message
|
||||
* - DELETE /sessions/:id/conversations/:cid — delete a message by index
|
||||
*/
|
||||
import type { ApiState } from "../state.ts";
|
||||
import { ApiError } from "../error.ts";
|
||||
import { Roles, type ChatMessage } from "@zesdex/domain";
|
||||
import type {
|
||||
AddMessageRequest,
|
||||
ConversationResponse,
|
||||
MessageResponse,
|
||||
ToolCallResponse,
|
||||
} from "../dto.ts";
|
||||
import type { Conversation } from "@zesdex/domain";
|
||||
|
||||
/** Map a single ChatMessage to its wire DTO. */
|
||||
export function toMessageResponse(m: ChatMessage): MessageResponse {
|
||||
let tool_calls: ToolCallResponse[] | undefined;
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
tool_calls = m.tool_calls.map((tc) => ({
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: JSON.stringify(tc.function.arguments),
|
||||
},
|
||||
}));
|
||||
}
|
||||
const out: MessageResponse = { role: m.role };
|
||||
if (m.content !== null && m.content !== undefined) out.content = m.content;
|
||||
if (tool_calls) out.tool_calls = tool_calls;
|
||||
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map a domain Conversation to its wire DTO. */
|
||||
export function toConversationResponse(c: Conversation): ConversationResponse {
|
||||
return {
|
||||
session_id: c.session_id,
|
||||
messages: c.messages.map(toMessageResponse),
|
||||
message_count: c.messages.length,
|
||||
model: c.model,
|
||||
system_prompt: c.system_prompt,
|
||||
...(c.max_tokens !== undefined ? { max_tokens: c.max_tokens } : {}),
|
||||
...(c.temperature !== undefined ? { temperature: c.temperature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /sessions/:id/conversations — fetch the conversation for a session. */
|
||||
export async function getConversationHandler(
|
||||
state: ApiState,
|
||||
id: string,
|
||||
): Promise<ConversationResponse> {
|
||||
if (id === "") throw ApiError.badRequest("Session ID is required");
|
||||
const conversation = await state.conversation_service.loadConversation(id);
|
||||
return toConversationResponse(conversation);
|
||||
}
|
||||
|
||||
/** POST /sessions/:id/conversations — append a message to a conversation. */
|
||||
export async function addMessageHandler(
|
||||
state: ApiState,
|
||||
id: string,
|
||||
req: AddMessageRequest,
|
||||
): Promise<{ status: number; body: ConversationResponse }> {
|
||||
if (id === "") throw ApiError.badRequest("Session ID is required");
|
||||
if (req.content === "") throw ApiError.badRequest("Message content is required");
|
||||
|
||||
const role = req.role.toLowerCase();
|
||||
if (role !== "user" && role !== "assistant") {
|
||||
throw ApiError.badRequest(`Invalid role: ${req.role}`);
|
||||
}
|
||||
|
||||
const msg: ChatMessage = {
|
||||
role: role === "user" ? Roles.User : Roles.Assistant,
|
||||
content: req.content,
|
||||
};
|
||||
|
||||
const conversation = await state.conversation_service.loadConversation(id);
|
||||
await state.conversation_service.addMessage(conversation, msg);
|
||||
return { status: 200, body: toConversationResponse(conversation) };
|
||||
}
|
||||
|
||||
/** DELETE /sessions/:id/conversations/:cid — delete a message by index. */
|
||||
export async function deleteMessageHandler(
|
||||
state: ApiState,
|
||||
id: string,
|
||||
cid: string,
|
||||
): Promise<{ status: number }> {
|
||||
if (id === "") throw ApiError.badRequest("Session ID is required");
|
||||
|
||||
const index = Number.parseInt(cid, 10);
|
||||
if (Number.isNaN(index)) {
|
||||
throw ApiError.badRequest(`Invalid message index: ${cid}`);
|
||||
}
|
||||
|
||||
const conversation = await state.conversation_service.loadConversation(id);
|
||||
if (index >= conversation.messages.length) {
|
||||
throw ApiError.notFound(
|
||||
`Message index ${index} out of bounds (max: ${conversation.messages.length - 1})`,
|
||||
);
|
||||
}
|
||||
|
||||
conversation.messages.splice(index, 1);
|
||||
await state.conversation_service.saveConversation(conversation);
|
||||
return { status: 204 };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Session management handlers.
|
||||
* Mirrors `apps/interfaces/api/src/handlers/sessions.rs`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /sessions — list all sessions
|
||||
* - POST /sessions — create a new session
|
||||
* - DELETE /sessions/:id — archive/close a session
|
||||
*/
|
||||
import type { ApiState } from "../state.ts";
|
||||
import { ApiError } from "../error.ts";
|
||||
import type { CreateSessionRequest, SessionListResponse, SessionResponse } from "../dto.ts";
|
||||
import { newSessionId } from "@zesdex/domain";
|
||||
|
||||
/** Map a domain Session to its wire DTO. */
|
||||
export function toSessionResponse(s: {
|
||||
id: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
title: string;
|
||||
model: string;
|
||||
message_count: number;
|
||||
archived: boolean;
|
||||
summary?: string;
|
||||
}): SessionResponse {
|
||||
return {
|
||||
id: s.id,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
title: s.title,
|
||||
model: s.model,
|
||||
message_count: s.message_count,
|
||||
archived: s.archived,
|
||||
...(s.summary !== undefined ? { summary: s.summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /sessions — list all (non-archived) sessions. */
|
||||
export async function listSessionsHandler(
|
||||
state: ApiState,
|
||||
): Promise<SessionListResponse> {
|
||||
const sessions = await state.session_service.listAll();
|
||||
const sessionResponses = sessions.map(toSessionResponse);
|
||||
return { sessions: sessionResponses, total: sessionResponses.length };
|
||||
}
|
||||
|
||||
/** POST /sessions — create a new session. */
|
||||
export async function createSessionHandler(
|
||||
state: ApiState,
|
||||
req: CreateSessionRequest,
|
||||
): Promise<{ status: number; body: SessionResponse }> {
|
||||
if (req.title.trim() === "") {
|
||||
throw ApiError.badRequest("Session title is required");
|
||||
}
|
||||
const session = await state.session_service.createSession(req.title);
|
||||
return { status: 201, body: toSessionResponse(session) };
|
||||
}
|
||||
|
||||
/** DELETE /sessions/:id — archive/close a session (returns 204). */
|
||||
export async function deleteSessionHandler(
|
||||
state: ApiState,
|
||||
id: string,
|
||||
): Promise<{ status: number }> {
|
||||
if (id === "") {
|
||||
throw ApiError.badRequest("Session ID is required");
|
||||
}
|
||||
const parsed = newSessionId(id);
|
||||
if (!parsed.ok) {
|
||||
throw ApiError.badRequest(`Invalid session ID: ${parsed.error}`);
|
||||
}
|
||||
await state.session_service.archiveSession(parsed.value);
|
||||
return { status: 204 };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Zesdex REST API interface package.
|
||||
* Mirrors `apps/interfaces/api/`.
|
||||
*/
|
||||
export * from "./dto.ts";
|
||||
export { ApiError, HTTP_STATUS } from "./error.ts";
|
||||
export { RateLimiter, newApiState } from "./state.ts";
|
||||
export type { ApiState } from "./state.ts";
|
||||
export { authenticateRequest } from "./middleware/auth.ts";
|
||||
export { startApiServer } from "./server.ts";
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex API server — standalone entry point.
|
||||
* Mirrors the `--api` mode of the Rust gateway.
|
||||
*/
|
||||
import { newApiState, startApiServer } from "./index.ts";
|
||||
|
||||
function envOr(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return v && v !== "" ? v : fallback;
|
||||
}
|
||||
|
||||
const port = Number.parseInt(envOr("ZESDEX_API_PORT", "8080"), 10);
|
||||
const baseDir = envOr("ZESDEX_STORE", process.env.HOME ? `${process.env.HOME}/.local/share/zesdex` : ".");
|
||||
const jwtSecret = envOr("ZESDEX_JWT_SECRET", "dev-secret-change-me");
|
||||
const apiKey = envOr("OPENAI_API_KEY", "");
|
||||
const model = envOr("ZESDEX_MODEL", "claude-opus-5");
|
||||
const baseUrl = process.env.OPENAI_API_BASE ?? undefined;
|
||||
|
||||
const state = newApiState(baseDir, jwtSecret, apiKey, model, baseUrl);
|
||||
startApiServer(state, port);
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* JWT authentication middleware for the API.
|
||||
* Mirrors `apps/interfaces/api/src/middleware/auth.rs`.
|
||||
*
|
||||
* Validates the `Authorization: Bearer <token>` header and injects the
|
||||
* validated subject into `req.user`. Refresh tokens are rejected on
|
||||
* protected routes — only access tokens are accepted.
|
||||
*/
|
||||
import { verifyToken } from "@zesdex/infrastructure";
|
||||
import type { ApiState } from "../state.ts";
|
||||
|
||||
/** Claims decoded from a valid JWT, attached to the request. */
|
||||
export interface JwtClaims {
|
||||
sub: string;
|
||||
}
|
||||
|
||||
/** Result of authentication: either the subject or an error status/detail. */
|
||||
export type AuthResult = { ok: true; sub: string } | { ok: false; status: number; detail: string };
|
||||
|
||||
/**
|
||||
* Authenticate a request against the API state's JWT secret.
|
||||
* Reads the `Authorization: Bearer <token>` header, verifies the signature
|
||||
* and token type (must be `access`), and returns the subject on success.
|
||||
*/
|
||||
export function authenticateRequest(state: ApiState, headers: Headers): AuthResult {
|
||||
const authHeader = headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
|
||||
}
|
||||
const prefix = "Bearer ";
|
||||
if (!authHeader.startsWith(prefix)) {
|
||||
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
|
||||
}
|
||||
const token = authHeader.slice(prefix.length).trim();
|
||||
if (!token) {
|
||||
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
|
||||
}
|
||||
try {
|
||||
const claims = verifyToken(state.jwt_secret, token);
|
||||
if (claims.typ !== "access") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
detail: "refresh tokens are not accepted on protected routes",
|
||||
};
|
||||
}
|
||||
return { ok: true, sub: claims.sub };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 401, detail: `Invalid token: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Zesdex REST API — HTTP server and router (Bun.serve).
|
||||
* Mirrors `apps/interfaces/api/src/lib.rs`.
|
||||
*
|
||||
* Routes:
|
||||
* - Public: /api/v1/auth/{login,register,refresh}, /api/v1/health
|
||||
* - Protected (JWT): /api/v1/sessions..., /api/v1/chat/completions
|
||||
*
|
||||
* Uses Bun's native HTTP server — no external framework dependency.
|
||||
*/
|
||||
import type { ApiState } from "./state.ts";
|
||||
import { ApiError } from "./error.ts";
|
||||
import { errorBody } from "./dto.ts";
|
||||
import { authenticateRequest } from "./middleware/auth.ts";
|
||||
import { loginHandler, registerHandler, refreshHandler } from "./handlers/auth.ts";
|
||||
import {
|
||||
listSessionsHandler,
|
||||
createSessionHandler,
|
||||
deleteSessionHandler,
|
||||
} from "./handlers/sessions.ts";
|
||||
import {
|
||||
getConversationHandler,
|
||||
addMessageHandler,
|
||||
deleteMessageHandler,
|
||||
} from "./handlers/conversations.ts";
|
||||
import { chatCompletionsHandler } from "./handlers/chat.ts";
|
||||
|
||||
/** CORS headers applied to every response (permissive for local/dev). */
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
/** A handler's response: JSON body plus optional status code. */
|
||||
interface Result {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** Per-request context passed to handlers. */
|
||||
interface Ctx {
|
||||
state: ApiState;
|
||||
params: string[];
|
||||
body: unknown;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/** Route mapping: regex matches URL path after `/api/v1`. */
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
protected: boolean;
|
||||
handle: (ctx: Ctx) => Promise<Result>;
|
||||
}
|
||||
|
||||
function result(body: unknown, status = 200): Result {
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
function json(res: Result): Response {
|
||||
return new Response(JSON.stringify(res.body), {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/** Build the route table for the API. Matches against paths without the /api/v1 prefix. */
|
||||
function buildRoutes(): Route[] {
|
||||
return [
|
||||
// Auth — public (rate-limited inside handlers)
|
||||
{ method: "POST", pattern: /^\/auth\/login$/, protected: false, handle: (c) => loginHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
||||
{ method: "POST", pattern: /^\/auth\/register$/, protected: false, handle: (c) => registerHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
||||
{ method: "POST", pattern: /^\/auth\/refresh$/, protected: false, handle: (c) => refreshHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
|
||||
|
||||
// Health — public
|
||||
{ method: "GET", pattern: /^\/health$/, protected: false, handle: () => Promise.resolve(result({ status: "ok" })) },
|
||||
|
||||
// Sessions — protected
|
||||
{ method: "GET", pattern: /^\/sessions$/, protected: true, handle: (c) => listSessionsHandler(c.state).then((s) => result(s)) },
|
||||
{ method: "POST", pattern: /^\/sessions$/, protected: true, handle: async (c) => {
|
||||
const r = await createSessionHandler(c.state, c.body as { title: string });
|
||||
return result(r.body, r.status);
|
||||
} },
|
||||
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)$/, protected: true, handle: async (c) => {
|
||||
await deleteSessionHandler(c.state, c.params[0]!);
|
||||
return { status: 204, body: null };
|
||||
} },
|
||||
|
||||
// Conversations (sub-resource of sessions) — protected
|
||||
{ method: "GET", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
|
||||
const conv = await getConversationHandler(c.state, c.params[0]!);
|
||||
return result(conv);
|
||||
} },
|
||||
{ method: "POST", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
|
||||
const r = await addMessageHandler(c.state, c.params[0]!, c.body as { role: string; content: string });
|
||||
return result(r.body, r.status);
|
||||
} },
|
||||
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)\/conversations\/([^/]+)$/, protected: true, handle: async (c) => {
|
||||
await deleteMessageHandler(c.state, c.params[0]!, c.params[1]!);
|
||||
return { status: 204, body: null };
|
||||
} },
|
||||
|
||||
// Chat — protected
|
||||
{ method: "POST", pattern: /^\/chat\/completions$/, protected: true, handle: (c) => chatCompletionsHandler(c.state, c.body as never).then((s) => result(s)) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Handle a request against the router; maps ApiError → HTTP response. */
|
||||
async function handleRequest(state: ApiState, routes: Route[], req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
// Strip the /api/v1 version prefix so route patterns match cleanly.
|
||||
const fullPath = url.pathname;
|
||||
const path = fullPath.startsWith("/api/v1") ? fullPath.slice("/api/v1".length) : fullPath;
|
||||
const method = req.method;
|
||||
|
||||
// CORS preflight
|
||||
if (method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
const route = routes.find((r) => r.method === method && r.pattern.test(path));
|
||||
if (!route) {
|
||||
return json(result(errorBody("Not found", 404), 404));
|
||||
}
|
||||
|
||||
// JWT auth for protected routes (subject is validated but not currently exposed).
|
||||
if (route.protected) {
|
||||
const auth = authenticateRequest(state, req.headers);
|
||||
if (!auth.ok) {
|
||||
return json(result({ error: "Unauthorized", detail: auth.detail }, auth.status));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON body if present; read once and reuse.
|
||||
let body: unknown = undefined;
|
||||
const raw = await req.text();
|
||||
if (raw) {
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return json(result(errorBody("Bad request: malformed JSON body", 400), 400));
|
||||
}
|
||||
}
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
const ctx: Ctx = {
|
||||
state,
|
||||
params: match ? match.slice(1) : [],
|
||||
body,
|
||||
headers: req.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await route.handle(ctx);
|
||||
if (res.status === 204) return noContent();
|
||||
return json(res);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
const msg = e.exposeDetails ? e.message : "An internal error occurred";
|
||||
return json(result(errorBody(msg, e.status), e.status));
|
||||
}
|
||||
console.error("Unhandled error:", e);
|
||||
return json(result(errorBody("An internal error occurred", 500)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the API server on the given port. Returns a handle. */
|
||||
export function startApiServer(state: ApiState, port: number): { stop: () => void; port: number } {
|
||||
const routes = buildRoutes();
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
async fetch(req) {
|
||||
return handleRequest(state, routes, req);
|
||||
},
|
||||
});
|
||||
console.log(`zesdex-api listening on http://0.0.0.0:${server.port}`);
|
||||
return { stop: () => server.stop(true), port: server.port ?? port };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Shared application state for the REST API server.
|
||||
* Mirrors `apps/interfaces/api/src/state.rs`.
|
||||
*
|
||||
* `ApiState` holds all service implementations wired to concrete
|
||||
* infrastructure adapters. Constructed once at startup (composition root)
|
||||
* and shared across all requests.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
SessionServiceImpl,
|
||||
ConversationServiceImpl,
|
||||
SettingsServiceImpl,
|
||||
MemoryServiceImpl,
|
||||
} from "@zesdex/application";
|
||||
import {
|
||||
JsonSettingsRepository,
|
||||
JsonAppConfigRepository,
|
||||
JsonConversationRepository,
|
||||
MarkdownMemoryRepository,
|
||||
FileSystemSessionRepository,
|
||||
FileSystemSessionLockRepository,
|
||||
Argon2PasswordService,
|
||||
Hs256TokenService,
|
||||
LlmClient,
|
||||
} from "@zesdex/infrastructure";
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter for auth endpoints.
|
||||
* Mirrors `infrastructure::middleware::rate_limit::RateLimiter`.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private hits = new Map<string, number[]>();
|
||||
|
||||
/** Check whether `key` is within `max` requests per `windowSecs`. Returns true if allowed. */
|
||||
check(key: string, max: number, windowSecs: number): boolean {
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowSecs * 1000;
|
||||
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
|
||||
if (list.length >= max) {
|
||||
this.hits.set(key, list);
|
||||
return false;
|
||||
}
|
||||
list.push(now);
|
||||
this.hits.set(key, list);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete session repository wired from infrastructure. */
|
||||
const sessionRepo = new FileSystemSessionRepository();
|
||||
const sessionLockRepo = new FileSystemSessionLockRepository();
|
||||
const conversationRepo = new JsonConversationRepository();
|
||||
const settingsRepo = new JsonSettingsRepository();
|
||||
const appConfigRepo = new JsonAppConfigRepository();
|
||||
const memoryRepo = new MarkdownMemoryRepository();
|
||||
|
||||
/** Rooted, long-lived shared API state. */
|
||||
export interface ApiState {
|
||||
store_base_dir: string;
|
||||
jwt_secret: string;
|
||||
session_service: SessionServiceImpl;
|
||||
conversation_service: ConversationServiceImpl;
|
||||
settings_service: SettingsServiceImpl;
|
||||
memory_service: MemoryServiceImpl;
|
||||
password_service: Argon2PasswordService;
|
||||
token_service: Hs256TokenService;
|
||||
auth_rate_limiter: RateLimiter;
|
||||
/** Serializes read-modify-write of `users.json` (TOCTOU race guard). */
|
||||
users_lock: { lock: () => () => void };
|
||||
llm_client: LlmClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op lock that returns an unlock function.
|
||||
* Bun is single-threaded per process, so the JS event loop already
|
||||
* serialises synchronous read-modify-write of users.json — the lock is
|
||||
* retained for structural parity with the Rust mutex guard.
|
||||
*/
|
||||
function noopLock(): { lock: () => () => void } {
|
||||
return { lock: () => () => {} };
|
||||
}
|
||||
|
||||
/** Construct a new API state with all services wired to their defaults. */
|
||||
export function newApiState(
|
||||
baseDir: string,
|
||||
jwtSecret: string,
|
||||
llmApiKey: string,
|
||||
llmModel: string,
|
||||
llmBaseUrl?: string,
|
||||
): ApiState {
|
||||
const sessionsDir = path.join(baseDir, "sessions");
|
||||
const memoryDir = path.join(baseDir, "memories");
|
||||
|
||||
const session_service = new SessionServiceImpl(sessionRepo, sessionLockRepo, baseDir);
|
||||
const conversation_service = new ConversationServiceImpl(conversationRepo, sessionsDir);
|
||||
const settings_service = new SettingsServiceImpl(settingsRepo, appConfigRepo, baseDir);
|
||||
const memory_service = new MemoryServiceImpl(memoryRepo, memoryDir);
|
||||
|
||||
const token_service = new Hs256TokenService(jwtSecret);
|
||||
const llm = new LlmClient(llmApiKey, llmModel, llmBaseUrl);
|
||||
|
||||
return {
|
||||
store_base_dir: baseDir,
|
||||
jwt_secret: jwtSecret,
|
||||
session_service,
|
||||
conversation_service,
|
||||
settings_service,
|
||||
memory_service,
|
||||
password_service: new Argon2PasswordService(),
|
||||
token_service,
|
||||
auth_rate_limiter: new RateLimiter(),
|
||||
users_lock: noopLock(),
|
||||
llm_client: llm,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load the `users.json` map (username → password hash), or null if absent. */
|
||||
export function loadUsers(baseDir: string): Record<string, string> | null {
|
||||
const p = path.join(baseDir, "users.json");
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, string>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the users map to `users.json` (pretty-printed). */
|
||||
export function saveUsers(baseDir: string, users: Record<string, string>): void {
|
||||
const p = path.join(baseDir, "users.json");
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(users, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* CLI argument parsing and mode dispatch.
|
||||
* Mirrors `apps/gateway/src/main.rs` (clap → manual Bun.argv parsing).
|
||||
*
|
||||
* Modes (mutually exclusive): --daemon, --attach <session>, --api, --ws,
|
||||
* --grpc, --web; default is TUI single-process mode.
|
||||
*/
|
||||
|
||||
export interface CliOptions {
|
||||
daemon: boolean;
|
||||
attach: string | null;
|
||||
api: boolean;
|
||||
apiPort: number;
|
||||
ws: boolean;
|
||||
wsPort: number;
|
||||
grpc: boolean;
|
||||
grpcPort: number;
|
||||
web: boolean;
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
const HELP = `zesdex — autonomous AI coding agent with TUI
|
||||
|
||||
Usage: zesdex [OPTIONS]
|
||||
|
||||
Options:
|
||||
--daemon Run as background daemon with IPC socket
|
||||
--attach <session> Attach TUI client to a running daemon session
|
||||
--api Run REST API server
|
||||
--api-port <PORT> REST API port [default: 8080]
|
||||
--ws Run WebSocket server
|
||||
--ws-port <PORT> WebSocket port [default: 8081]
|
||||
--grpc Run gRPC server
|
||||
--grpc-port <PORT> gRPC port [default: 50051]
|
||||
--web Serve web frontend
|
||||
--web-port <PORT> Web frontend port [default: 3000]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
`;
|
||||
|
||||
/** Parse process argv (excluding node/bun) into CliOptions. */
|
||||
export function parseCli(argv: string[]): CliOptions | { help: true; text: string } {
|
||||
const opts: CliOptions = {
|
||||
daemon: false,
|
||||
attach: null,
|
||||
api: false,
|
||||
apiPort: 8080,
|
||||
ws: false,
|
||||
wsPort: 8081,
|
||||
grpc: false,
|
||||
grpcPort: 50051,
|
||||
web: false,
|
||||
webPort: 3000,
|
||||
};
|
||||
|
||||
let sawFlag = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { help: true, text: HELP };
|
||||
}
|
||||
if (arg === "--version" || arg === "-V") {
|
||||
return { help: true, text: `zesdex 1.21.2` };
|
||||
}
|
||||
const assignString = (key: keyof CliOptions, next: string | undefined, name: string) => {
|
||||
if (next === undefined) throw new Error(`flag '${name}' requires a value`);
|
||||
(opts as unknown as Record<string, unknown>)[key] = next;
|
||||
};
|
||||
const assignPort = (key: keyof CliOptions, next: string | undefined, name: string) => {
|
||||
if (next === undefined) throw new Error(`flag '${name}' requires a value`);
|
||||
const n = Number(next);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
||||
throw new Error(`invalid port for '${name}': ${next}`);
|
||||
}
|
||||
(opts as unknown as Record<string, unknown>)[key] = n;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--daemon":
|
||||
opts.daemon = true;
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--attach":
|
||||
assignString("attach", argv[++i], "--attach");
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--api":
|
||||
opts.api = true;
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--api-port":
|
||||
assignPort("apiPort", argv[++i], "--api-port");
|
||||
break;
|
||||
case "--ws":
|
||||
opts.ws = true;
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--ws-port":
|
||||
assignPort("wsPort", argv[++i], "--ws-port");
|
||||
break;
|
||||
case "--grpc":
|
||||
opts.grpc = true;
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--grpc-port":
|
||||
assignPort("grpcPort", argv[++i], "--grpc-port");
|
||||
break;
|
||||
case "--web":
|
||||
opts.web = true;
|
||||
sawFlag = true;
|
||||
break;
|
||||
case "--web-port":
|
||||
assignPort("webPort", argv[++i], "--web-port");
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected argument '${arg}'\n\n${HELP}`);
|
||||
}
|
||||
}
|
||||
void sawFlag;
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** Validate that at most one mode flag is set. */
|
||||
export function modeCount(opts: CliOptions): number {
|
||||
let count = opts.daemon ? 1 : 0;
|
||||
if (opts.api) count++;
|
||||
if (opts.ws) count++;
|
||||
if (opts.grpc) count++;
|
||||
if (opts.web) count++;
|
||||
if (opts.attach !== null) count++;
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Bootstrap seed — create the initial settings.json / app_config.json if missing.
|
||||
* Mirrors the Rust `bootstrap` seed.
|
||||
*/
|
||||
import { newStore, ensureStoreDirs } from "@zesdex/domain";
|
||||
import { JsonSettingsRepository, JsonAppConfigRepository } from "@zesdex/infrastructure";
|
||||
|
||||
/** Seed default settings + app config into the store directory. */
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const store = newStore();
|
||||
await ensureStoreDirs(store);
|
||||
|
||||
const settingsRepo = new JsonSettingsRepository();
|
||||
const appConfigRepo = new JsonAppConfigRepository();
|
||||
|
||||
const settings = await settingsRepo.load(store.base_dir);
|
||||
const appConfig = await appConfigRepo.load(store.base_dir);
|
||||
|
||||
if (!settings.api_keys[settings.provider]) {
|
||||
console.info(`provider '${settings.provider}' has no API key configured.`);
|
||||
}
|
||||
|
||||
console.info(`seeded defaults at ${store.base_dir}`);
|
||||
console.info(` provider: ${settings.provider}`);
|
||||
console.info(` model: ${settings.model}`);
|
||||
console.info(` providers: ${Object.keys(appConfig.providers).join(", ") || "(none)"}`);
|
||||
}
|
||||
|
||||
// Run when invoked directly as `bun src/bootstrap.ts`.
|
||||
if (import.meta.main) {
|
||||
bootstrap().catch((e) => {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Single-process composition root — wires every concrete implementation once.
|
||||
* Mirrors the `run_single_process` composition in the Rust gateway/TUI.
|
||||
*
|
||||
* Assembles: Store → Settings/AppConfig repos → LLM provider → ToolCtx +
|
||||
* executor → AgentTurnServiceImpl.
|
||||
*/
|
||||
import {
|
||||
newStore,
|
||||
ensureStoreDirs,
|
||||
type AgentTurnParams,
|
||||
type TurnEvent,
|
||||
type TurnEventSink,
|
||||
type ChatMessage,
|
||||
type Store,
|
||||
userMessage,
|
||||
resolveEffectiveModel,
|
||||
} from "@zesdex/domain";
|
||||
import {
|
||||
JsonSettingsRepository,
|
||||
JsonAppConfigRepository,
|
||||
LlmClient,
|
||||
resolveApiKey,
|
||||
InfrastructureToolExecutor,
|
||||
allTools,
|
||||
toolDefs,
|
||||
} from "@zesdex/infrastructure";
|
||||
import type { ToolCtx } from "@zesdex/infrastructure";
|
||||
import { AgentTurnServiceImpl } from "@zesdex/application";
|
||||
|
||||
/** Wired runtime for single-process mode. */
|
||||
export interface WiredRuntime {
|
||||
store: Store;
|
||||
executor: InfrastructureToolExecutor;
|
||||
turnService: AgentTurnServiceImpl;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire all layers together once.
|
||||
* Returns the assembled runtime that both the REPL loop and future
|
||||
* interface servers share.
|
||||
*/
|
||||
export async function runSingleProcess(): Promise<WiredRuntime> {
|
||||
const store = newStore();
|
||||
await ensureStoreDirs(store);
|
||||
|
||||
// Load settings + app config from the store directory (repos are async).
|
||||
const settingsRepo = new JsonSettingsRepository();
|
||||
const appConfigRepo = new JsonAppConfigRepository();
|
||||
const settings = await settingsRepo.load(store.base_dir);
|
||||
const appConfig = await appConfigRepo.load(store.base_dir);
|
||||
|
||||
// Resolve the effective provider / model / base URL / API key.
|
||||
const provider = settings.provider;
|
||||
const model = resolveEffectiveModel(settings, appConfig);
|
||||
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
|
||||
const apiKey = resolveApiKey(settings, appConfig);
|
||||
|
||||
// Build the LLM provider service (implements the ProviderService port).
|
||||
const llmClient = new LlmClient(apiKey, model, baseUrl);
|
||||
|
||||
// Build the tool context + executor.
|
||||
const executor = new InfrastructureToolExecutor(buildToolCtx(store));
|
||||
|
||||
// Assemble the turn service (provider + executor + tool definitions).
|
||||
const turnService = new AgentTurnServiceImpl(
|
||||
llmClient,
|
||||
executor,
|
||||
toolDefs(allTools()) as never,
|
||||
);
|
||||
|
||||
return { store, executor, turnService, apiKey, model, apiBase: baseUrl };
|
||||
}
|
||||
|
||||
/** Rooted, long-lived event sink that collects events into an array. */
|
||||
export class ArrayTurnEventSink implements TurnEventSink {
|
||||
constructor(public events: TurnEvent[] = []) {}
|
||||
push(event: TurnEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
drain(): TurnEvent[] {
|
||||
const out = this.events;
|
||||
this.events = [];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a fresh set of turn params for a single user query. */
|
||||
export function buildTurnParams(
|
||||
runtime: WiredRuntime,
|
||||
userText: string,
|
||||
events: TurnEvent[],
|
||||
abort: AbortController,
|
||||
): AgentTurnParams {
|
||||
const sink = new ArrayTurnEventSink(events);
|
||||
const messages: ChatMessage[] = [userMessage(userText)];
|
||||
return {
|
||||
messages,
|
||||
session_dir: runtime.store.base_dir,
|
||||
workspace_roots: [process.cwd()],
|
||||
turn_events: sink,
|
||||
in_flight: { value: false },
|
||||
abort,
|
||||
api_key: runtime.apiKey,
|
||||
model: runtime.model,
|
||||
api_base: runtime.apiBase,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a ToolCtx with the standard fields. */
|
||||
function buildToolCtx(store: Store): ToolCtx {
|
||||
return {
|
||||
sessionDir: store.base_dir,
|
||||
workspaces: [process.cwd()],
|
||||
turnEvents: new ArrayTurnEventSink(),
|
||||
workflowFindings: [],
|
||||
} as unknown as ToolCtx;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex CLI — main entry point.
|
||||
* Mirrors `apps/gateway/src/main.rs`.
|
||||
*
|
||||
* Parses argv, sets up file logging, and dispatches to the requested mode.
|
||||
* Default mode runs the single-process REPL loop.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { newStore } from "@zesdex/domain";
|
||||
import { parseCli, modeCount } from "./args.ts";
|
||||
import { runSingleProcess } from "./compose.ts";
|
||||
|
||||
/** Initialise file logging (append to <data-dir>/zesdex/zesdex.log). */
|
||||
function initLogging(): void {
|
||||
const dataDir = os.homedir();
|
||||
const logDir = path.join(dataDir, ".local", "share", "zesdex");
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, "zesdex.log");
|
||||
const stream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
const originalInfo = console.info;
|
||||
|
||||
const write = (level: string, args: unknown[]) => {
|
||||
const line = `[${new Date().toISOString()}] ${level} ${args
|
||||
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
||||
.join(" ")}`;
|
||||
stream.write(line + "\n");
|
||||
if (level === "ERROR") originalError(...args);
|
||||
else if (level === "WARN") originalWarn(...args);
|
||||
else if (level === "INFO") originalInfo(...args);
|
||||
else originalLog(...args);
|
||||
};
|
||||
|
||||
console.log = (...a: unknown[]) => write("LOG", a);
|
||||
console.info = (...a: unknown[]) => write("INFO", a);
|
||||
console.warn = (...a: unknown[]) => write("WARN", a);
|
||||
console.error = (...a: unknown[]) => write("ERROR", a);
|
||||
}
|
||||
|
||||
/** Run the REPL loop (default single-process mode). */
|
||||
async function runRepl(): Promise<void> {
|
||||
const runtime = await runSingleProcess();
|
||||
console.info("zesdex ready — single-process REPL mode. Type `exit` to quit.");
|
||||
|
||||
// Minimal REPL over stdin.
|
||||
// Use node readline.
|
||||
const readline = (await import("node:readline")).default;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
rl.on("line", async (line: string) => {
|
||||
const text = line.trim();
|
||||
if (text === "exit" || text === "quit" || text === "/exit") {
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
if (text === "" || text.startsWith("/")) {
|
||||
if (text === "/help") {
|
||||
console.log("Commands: type a message to talk to the agent; `exit` to quit.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const events: import("@zesdex/domain").TurnEvent[] = [];
|
||||
const abort = new AbortController();
|
||||
const params = buildTurnParamsFrom(runtime, text, events, abort);
|
||||
|
||||
process.stdout.write("…");
|
||||
try {
|
||||
await runtime.turnService.runTurn(params);
|
||||
// Print the final assistant content from events.
|
||||
const lastTokens: string[] = [];
|
||||
for (const ev of events) {
|
||||
if (ev.kind === "stream_token") lastTokens.push(ev.content);
|
||||
}
|
||||
const full = lastTokens.join("");
|
||||
process.stdout.write("\n");
|
||||
if (full.trim() !== "") console.log(full.trim());
|
||||
else console.log("(no response content)");
|
||||
} catch (e) {
|
||||
console.error(`turn failed: ${(e as Error).message}`);
|
||||
}
|
||||
rl.prompt();
|
||||
});
|
||||
|
||||
rl.prompt();
|
||||
}
|
||||
|
||||
// Imported lazily to avoid a circular type-only dependency.
|
||||
import { buildTurnParams } from "./compose.ts";
|
||||
function buildTurnParamsFrom(
|
||||
runtime: Parameters<typeof buildTurnParams>[0],
|
||||
text: string,
|
||||
events: import("@zesdex/domain").TurnEvent[],
|
||||
abort: AbortController,
|
||||
) {
|
||||
return buildTurnParams(runtime, text, events, abort);
|
||||
}
|
||||
|
||||
/** Main entry. */
|
||||
async function main(): Promise<void> {
|
||||
initLogging();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseCli(args);
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if ("help" in parsed) {
|
||||
console.log(parsed.text);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const opts = parsed;
|
||||
if (modeCount(opts) > 1) {
|
||||
console.error(
|
||||
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (opts.daemon) {
|
||||
const { runDaemon } = await import("@zesdex/daemon");
|
||||
console.info("daemon mode — creating per-session IPC socket");
|
||||
const handle = await runDaemon();
|
||||
const stop = () => void handle.stop().then(() => process.exit(0));
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
await new Promise(() => {});
|
||||
} else if (opts.attach !== null) {
|
||||
const { runAttach } = await import("@zesdex/daemon");
|
||||
console.info(`attach mode for daemon session '${opts.attach}'`);
|
||||
const store = newStore();
|
||||
const socketPath = path.join(store.base_dir, "run", `${opts.attach}.sock`);
|
||||
await runAttach(socketPath);
|
||||
} else if (opts.api) {
|
||||
const { newApiState, startApiServer } = await import("@zesdex/api");
|
||||
const baseDir =
|
||||
process.env.ZESDEX_STORE ?? path.join(os.homedir(), ".local", "share", "zesdex");
|
||||
const state = newApiState(
|
||||
baseDir,
|
||||
process.env.ZESDEX_JWT_SECRET ?? "dev-secret-change-me",
|
||||
process.env.OPENAI_API_KEY ?? "",
|
||||
process.env.ZESDEX_MODEL ?? "claude-opus-5",
|
||||
process.env.OPENAI_API_BASE ?? undefined,
|
||||
);
|
||||
startApiServer(state, opts.apiPort);
|
||||
await new Promise(() => {});
|
||||
} else if (opts.ws) {
|
||||
const { startWsServer } = await import("@zesdex/ws");
|
||||
startWsServer(opts.wsPort);
|
||||
await new Promise(() => {});
|
||||
} else if (opts.grpc) {
|
||||
const { startGrpcServer } = await import("@zesdex/grpc");
|
||||
startGrpcServer(opts.grpcPort);
|
||||
await new Promise(() => {});
|
||||
} else if (opts.web) {
|
||||
const { startWebServer } = await import("@zesdex/web");
|
||||
startWebServer(opts.webPort, process.env.ZESDEX_WEB_DIR ?? undefined);
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
// Default: single-process REPL.
|
||||
await runRepl();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Daemon client — connects to a running daemon over its Unix socket and
|
||||
* exchanges framed IPC messages. Mirrors `apps/interfaces/daemon/src/client.rs`.
|
||||
*/
|
||||
import { IpcClient } from "@zesdex/infrastructure";
|
||||
import type { DaemonFrame, ClientRequest } from "@zesdex/infrastructure";
|
||||
|
||||
/**
|
||||
* Connect to a running daemon socket and send one or more requests,
|
||||
* printing the frames received back. Returns when the daemon closes or
|
||||
* after a timeout.
|
||||
*/
|
||||
export async function runAttach(
|
||||
socketPath: string,
|
||||
initialText?: string,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
const client = await IpcClient.connectUnix(socketPath);
|
||||
|
||||
// Send a submit if provided, so the daemon starts a turn.
|
||||
if (initialText) {
|
||||
const req: ClientRequest = { kind: "Submit", text: initialText };
|
||||
client.send(req);
|
||||
}
|
||||
|
||||
// Read frames until closed or timeout.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const frame = await client.receive<DaemonFrame>();
|
||||
if (frame === null) break; // daemon closed connection
|
||||
switch (frame.kind) {
|
||||
case "StreamToken":
|
||||
process.stdout.write(frame.token);
|
||||
break;
|
||||
case "StateUpdate":
|
||||
process.stdout.write(`\n[state: ${frame.payload.message_count} msgs]\n`);
|
||||
break;
|
||||
case "Closed":
|
||||
process.stdout.write("\n[closed]\n");
|
||||
client.close();
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
client.close();
|
||||
}
|
||||
|
||||
/** Connect and send a Close request, for shutdown testing. */
|
||||
export async function sendClose(socketPath: string): Promise<void> {
|
||||
const client = await IpcClient.connectUnix(socketPath);
|
||||
const req: ClientRequest = { kind: "Close" };
|
||||
client.send(req);
|
||||
// Give the daemon a moment to reply Closed.
|
||||
const frame = await client.receive<DaemonFrame>();
|
||||
if (frame && frame.kind === "Closed") {
|
||||
process.stdout.write("[closed]\n");
|
||||
}
|
||||
client.close();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Zesdex daemon interface package.
|
||||
* Mirrors `apps/interfaces/daemon/`.
|
||||
*/
|
||||
export { runDaemon } from "./server.ts";
|
||||
export type { DaemonHandle } from "./server.ts";
|
||||
export { runAttach, sendClose } from "./client.ts";
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex daemon — standalone entry point.
|
||||
* Mirrors the `--daemon` mode of the Rust gateway.
|
||||
*/
|
||||
import { runDaemon } from "./index.ts";
|
||||
|
||||
const handle = await runDaemon();
|
||||
console.log("daemon running — Ctrl+C to stop");
|
||||
|
||||
// Keep alive until SIGINT/SIGTERM.
|
||||
let stopping = false;
|
||||
async function shutdown(): Promise<void> {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await handle.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", () => void shutdown());
|
||||
process.on("SIGTERM", () => void shutdown());
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Daemon server — owns the agent state, listens on a per-session Unix socket,
|
||||
* and drives requests from an attached client.
|
||||
* Mirrors `apps/interfaces/daemon/src/server.rs` (simplified: focuses on the
|
||||
* IPC agent-driver loop; full TUI key-handling lives in the Fase 4 TUI).
|
||||
*
|
||||
* Flow: `runDaemon()` creates a session → binds a Unix socket under
|
||||
* `<store>/run/<session_id>.sock` → accepts a client → loops reading
|
||||
* `ClientRequest`s. On `Submit` it runs an agent turn and streams
|
||||
* `StreamToken`/`StateUpdate` frames back. On `Close`/disconnect it cleans
|
||||
* up the socket file.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { IpcServer } from "@zesdex/infrastructure";
|
||||
import {
|
||||
JsonSettingsRepository,
|
||||
JsonAppConfigRepository,
|
||||
LlmClient,
|
||||
resolveApiKey,
|
||||
InfrastructureToolExecutor,
|
||||
allTools,
|
||||
toolDefs,
|
||||
} from "@zesdex/infrastructure";
|
||||
import type { ToolCtx } from "@zesdex/infrastructure";
|
||||
import { AgentTurnServiceImpl } from "@zesdex/application";
|
||||
import {
|
||||
newStore,
|
||||
ensureStoreDirs,
|
||||
newSessionId,
|
||||
resolveEffectiveModel,
|
||||
type TurnEvent,
|
||||
type TurnEventSink,
|
||||
type AgentTurnParams,
|
||||
userMessage,
|
||||
} from "@zesdex/domain";
|
||||
import type { ClientRequest, DaemonFrame, MessageEntry, StatePayload } from "@zesdex/infrastructure";
|
||||
|
||||
/** A running daemon handle. */
|
||||
export interface DaemonHandle {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Array-backed turn event sink (push + drain). */
|
||||
export class ArraySink implements TurnEventSink {
|
||||
constructor(public events: TurnEvent[] = []) {}
|
||||
push(event: TurnEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
drain(): TurnEvent[] {
|
||||
const out = this.events;
|
||||
this.events = [];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire a fresh agent runtime (mirrors the CLI composition root). */
|
||||
async function buildRuntime() {
|
||||
const store = newStore();
|
||||
await ensureStoreDirs(store);
|
||||
|
||||
const settingsRepo = new JsonSettingsRepository();
|
||||
const appConfigRepo = new JsonAppConfigRepository();
|
||||
const settings = await settingsRepo.load(store.base_dir);
|
||||
const appConfig = await appConfigRepo.load(store.base_dir);
|
||||
|
||||
const provider = settings.provider;
|
||||
const model = resolveEffectiveModel(settings, appConfig);
|
||||
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
|
||||
const apiKey = resolveApiKey(settings, appConfig);
|
||||
|
||||
const llmClient = new LlmClient(apiKey, model, baseUrl);
|
||||
const toolCtx = {
|
||||
sessionDir: store.base_dir,
|
||||
workspaces: [process.cwd()],
|
||||
turnEvents: new ArraySink(),
|
||||
workflowFindings: [],
|
||||
} as unknown as ToolCtx;
|
||||
const executor = new InfrastructureToolExecutor(toolCtx);
|
||||
const turnService = new AgentTurnServiceImpl(llmClient, executor, toolDefs(allTools()) as never);
|
||||
|
||||
const buildParams = (message: string, sink: TurnEventSink): AgentTurnParams => ({
|
||||
messages: [userMessage(message)],
|
||||
session_dir: store.base_dir,
|
||||
workspace_roots: [process.cwd()],
|
||||
turn_events: sink,
|
||||
in_flight: { value: false },
|
||||
abort: new AbortController(),
|
||||
api_key: apiKey,
|
||||
model,
|
||||
api_base: baseUrl,
|
||||
});
|
||||
|
||||
return { store, turnService, buildParams };
|
||||
}
|
||||
|
||||
/** Build a StatePayload snapshot from the accumulated messages. */
|
||||
function snapshot(sessionId: string, messages: MessageEntry[], dirty: boolean): StatePayload {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages,
|
||||
edit_count: 0,
|
||||
message_count: messages.length,
|
||||
overlay: null,
|
||||
toasts: [],
|
||||
dirty,
|
||||
input_buffer: "",
|
||||
input_cursor: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle one attached client: process its requests, driving agent turns. */
|
||||
async function handleDaemonClient(
|
||||
conn: { send<T>(m: T): void; receiveJson<T>(): Promise<T | null>; close(): void },
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const runtime = await buildRuntime();
|
||||
const messages: MessageEntry[] = [];
|
||||
|
||||
const pushState = (dirty: boolean) =>
|
||||
conn.send<DaemonFrame>({
|
||||
kind: "StateUpdate",
|
||||
payload: snapshot(sessionId, messages, dirty),
|
||||
});
|
||||
|
||||
pushState(false);
|
||||
|
||||
let running = true;
|
||||
while (running) {
|
||||
let req: ClientRequest | null = null;
|
||||
try {
|
||||
req = await conn.receiveJson<ClientRequest>();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (req === null) break; // client closed
|
||||
|
||||
switch (req.kind) {
|
||||
case "Submit": {
|
||||
const text = req.text;
|
||||
if (text.trim() === "") break;
|
||||
messages.push({ role: "user", content: text, timestamp: Date.now() });
|
||||
pushState(true);
|
||||
|
||||
// Run the turn and stream tokens as they arrive.
|
||||
const sink = new ArraySink();
|
||||
const params = runtime.buildParams(text, sink);
|
||||
void runTurnAndStream(conn, sink, runtime.turnService, params).then(() => {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: sink.events
|
||||
.filter((e) => e.kind === "stream_token")
|
||||
.map((e) => (e as { content: string }).content)
|
||||
.join(""),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
pushState(false);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "Close":
|
||||
running = false;
|
||||
conn.send<DaemonFrame>({ kind: "Closed" });
|
||||
break;
|
||||
default:
|
||||
// KeyPress/Tick/etc. no-op in this simplified daemon.
|
||||
break;
|
||||
}
|
||||
}
|
||||
conn.close();
|
||||
}
|
||||
|
||||
/** Run a turn, streaming token frames to the client as they are produced. */
|
||||
async function runTurnAndStream(
|
||||
conn: { send<T>(m: T): void },
|
||||
sink: ArraySink,
|
||||
turnService: AgentTurnServiceImpl,
|
||||
params: AgentTurnParams,
|
||||
): Promise<void> {
|
||||
const poll = setInterval(() => {
|
||||
for (const ev of sink.drain()) {
|
||||
if (ev.kind === "stream_token") {
|
||||
conn.send<DaemonFrame>({ kind: "StreamToken", token: (ev as { content: string }).content });
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
await turnService.runTurn(params);
|
||||
} finally {
|
||||
clearInterval(poll);
|
||||
// Drain any remaining tokens after the turn completes.
|
||||
for (const ev of sink.drain()) {
|
||||
if (ev.kind === "stream_token") {
|
||||
conn.send<DaemonFrame>({ kind: "StreamToken", token: (ev as { content: string }).content });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Run zesdex as a background daemon owning agent state over a Unix socket. */
|
||||
export async function runDaemon(): Promise<DaemonHandle> {
|
||||
const store = newStore();
|
||||
await ensureStoreDirs(store);
|
||||
|
||||
const idRes = newSessionId(randomUUID());
|
||||
const sessionId = idRes.ok ? idRes.value : `sess-${Date.now()}`;
|
||||
fs.mkdirSync(path.join(store.base_dir, "sessions", sessionId), { recursive: true });
|
||||
|
||||
const runDir = path.join(store.base_dir, "run");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const socketPath = path.join(runDir, `${sessionId}.sock`);
|
||||
|
||||
const server = await IpcServer.bindUnix(socketPath);
|
||||
console.log(`zesdex-daemon listening on ${socketPath}`);
|
||||
|
||||
// Accept clients serially.
|
||||
void (async () => {
|
||||
for (;;) {
|
||||
try {
|
||||
const conn = await server.accept();
|
||||
console.log("daemon client connected");
|
||||
try {
|
||||
await handleDaemonClient(
|
||||
{ send: (m) => conn.send(m), receiveJson: () => conn.receiveJson(), close: () => conn.close() },
|
||||
sessionId,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("daemon client error:", e);
|
||||
}
|
||||
console.log("daemon client disconnected");
|
||||
} catch {
|
||||
break; // server closed
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
await server.close();
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Zesdex gRPC interface package.
|
||||
* Mirrors `apps/interfaces/grpc/`.
|
||||
*/
|
||||
export { startGrpcServer, handleGrpcRequest } from "./server.ts";
|
||||
export type { GrpcState } from "./server.ts";
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex gRPC server — standalone entry point (HTTP health stub).
|
||||
* Mirrors the `--grpc` mode of the Rust gateway.
|
||||
*/
|
||||
import { startGrpcServer } from "./index.ts";
|
||||
|
||||
const port = Number.parseInt(process.env.ZESDEX_GRPC_PORT ?? "50051", 10);
|
||||
startGrpcServer(port);
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex gRPC interface — high-performance RPC with protobuf.
|
||||
* Mirrors `apps/interfaces/grpc/src/lib.rs`.
|
||||
*
|
||||
* For now this crate provides a minimal HTTP health-check endpoint so
|
||||
* consumers can verify the gRPC server is reachable. Full gRPC would use
|
||||
* tonic+prost (Rust) — in Bun/TS a real gRPC server would use `@grpc/grpc-js`
|
||||
* + generated protobuf types. This is intentionally a health stub.
|
||||
*/
|
||||
|
||||
/** gRPC server state (minimal for health checks). */
|
||||
export interface GrpcState {
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** Build the gRPC health router. */
|
||||
export function handleGrpcRequest(_state: GrpcState, req: Request): Response {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/grpc/health") {
|
||||
return new Response("gRPC server is running", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
/** Start the gRPC server (HTTP health stub). */
|
||||
export function startGrpcServer(port: number): { stop: () => void } {
|
||||
const state: GrpcState = { version: "1.21.2" };
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch(req) {
|
||||
return handleGrpcRequest(state, req);
|
||||
},
|
||||
});
|
||||
console.log(`zesdex-grpc listening on http://0.0.0.0:${server.port}`);
|
||||
console.log("Note: gRPC currently runs HTTP health endpoint. Add @grpc/grpc-js for full gRPC.");
|
||||
return { stop: () => server.stop(true) };
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The `Action` — a single well-typed event in the TUI, produced by key input
|
||||
* and applied to `AppStateRest` by the event loop. Mirrors
|
||||
* `apps/interfaces/tui/src/action/mod.rs`.
|
||||
*
|
||||
* Flow: `handleKey` returns `Action[]` → the event loop calls `applyAction`
|
||||
* for each → state is mutated in place. Every state mutation funnels through
|
||||
* this single chokepoint.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import type { AppStateRest, Overlay } from "./state.ts";
|
||||
import {
|
||||
makeChatMessage,
|
||||
pushTranscript,
|
||||
toastInfo,
|
||||
markDirty,
|
||||
EditorState,
|
||||
} from "./state.ts";
|
||||
import { gitDiffOutput } from "./git.ts";
|
||||
|
||||
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
||||
export type Action =
|
||||
| { tag: "ForceQuit" }
|
||||
| { tag: "SubmitInput"; text: string }
|
||||
| { tag: "DeleteChar" }
|
||||
| { tag: "DeleteCharRight" }
|
||||
| { tag: "CursorLeft" }
|
||||
| { tag: "CursorRight" }
|
||||
| { tag: "HistoryUp" }
|
||||
| { tag: "HistoryDown" }
|
||||
| { tag: "ScrollUp" }
|
||||
| { tag: "ScrollDown" }
|
||||
| { tag: "OpenOverlay"; overlay: Overlay }
|
||||
| { tag: "CloseOverlay" }
|
||||
| { tag: "SystemNote"; kind: string; message: string }
|
||||
| { tag: "QuitConfirm" }
|
||||
| { tag: "Resize"; width: number; height: number }
|
||||
| { tag: "Tick" }
|
||||
| { tag: "LessonAccept"; name: string }
|
||||
| { tag: "LessonReject"; name: string }
|
||||
| { tag: "LessonDelete"; name: string }
|
||||
| { tag: "StartOAuth"; provider: string }
|
||||
| { tag: "OpenEditor"; path: string }
|
||||
| { tag: "McpAdd"; name: string; command: string }
|
||||
| { tag: "ModelList" }
|
||||
| { tag: "AbortTurn" }
|
||||
| { tag: "Compact" }
|
||||
| { tag: "ShowDiff" }
|
||||
| { tag: "DiffScroll"; amount: number };
|
||||
|
||||
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
|
||||
export function applyAction(state: AppStateRest, action: Action): void {
|
||||
switch (action.tag) {
|
||||
case "ForceQuit":
|
||||
state.quit = true;
|
||||
break;
|
||||
|
||||
case "QuitConfirm":
|
||||
state.misc.overlay = "quit_confirm";
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "Resize":
|
||||
if (state.lastRenderWidth !== action.width) {
|
||||
state.transcriptCache.dirty = true;
|
||||
state.lastRenderWidth = action.width;
|
||||
}
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "Tick":
|
||||
handleTick(state);
|
||||
break;
|
||||
|
||||
case "SubmitInput":
|
||||
pushTranscript(state, makeChatMessage(Roles.User, action.text));
|
||||
state.input.submit();
|
||||
state.pendingSubmit = action.text;
|
||||
state.tokenCountDirty = true;
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "DeleteChar":
|
||||
state.input.deleteLeft();
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "DeleteCharRight":
|
||||
state.input.deleteRight();
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "CursorLeft":
|
||||
state.input.cursor = Math.max(0, state.input.cursor - 1);
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "CursorRight":
|
||||
if (state.input.cursor < state.input.buffer.length) state.input.cursor += 1;
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "HistoryUp":
|
||||
state.input.historyUp();
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "HistoryDown":
|
||||
state.input.historyDown();
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "ScrollUp":
|
||||
state.scroll.scrollUp(3);
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "ScrollDown":
|
||||
state.scroll.scrollDown(3);
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "OpenOverlay":
|
||||
state.misc.overlay = action.overlay;
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "CloseOverlay":
|
||||
state.misc.overlay = "none";
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "SystemNote":
|
||||
pushTranscript(state, makeChatMessage(Roles.System, action.message));
|
||||
break;
|
||||
|
||||
case "ModelList":
|
||||
state.misc.overlay = "model_selector";
|
||||
state.dirty = true;
|
||||
break;
|
||||
|
||||
case "OpenEditor":
|
||||
{
|
||||
const content = fs.existsSync(action.path)
|
||||
? fs.readFileSync(action.path, "utf8")
|
||||
: "";
|
||||
state.misc.editor = new EditorState(action.path, content);
|
||||
state.misc.overlay = "editor";
|
||||
state.dirty = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case "LessonAccept":
|
||||
toastInfo(state, `Lesson accepted: ${action.name}`);
|
||||
break;
|
||||
case "LessonReject":
|
||||
toastInfo(state, `Lesson rejected: ${action.name}`);
|
||||
break;
|
||||
case "LessonDelete":
|
||||
toastInfo(state, `Lesson deleted: ${action.name}`);
|
||||
break;
|
||||
|
||||
case "StartOAuth":
|
||||
toastInfo(state, `OAuth login started for ${action.provider}`);
|
||||
break;
|
||||
|
||||
case "McpAdd":
|
||||
toastInfo(state, `MCP server added: ${action.name} (${action.command})`);
|
||||
break;
|
||||
|
||||
case "AbortTurn":
|
||||
state.abortFlag.value = true;
|
||||
toastInfo(state, "Aborting current turn...");
|
||||
break;
|
||||
|
||||
case "Compact":
|
||||
toastInfo(state, "Compacting conversation...");
|
||||
break;
|
||||
|
||||
case "ShowDiff":
|
||||
{
|
||||
state.misc.diffContent = gitDiffOutput(state.workspaceRoots);
|
||||
state.misc.diffScroll = 0;
|
||||
state.misc.overlay = "diff";
|
||||
state.dirty = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case "DiffScroll":
|
||||
{
|
||||
const maxScroll = Math.max(0, state.misc.diffContent.split("\n").length - 1);
|
||||
const newScroll = Math.max(0, state.misc.diffScroll + action.amount);
|
||||
state.misc.diffScroll = Math.min(newScroll, maxScroll);
|
||||
state.dirty = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic timer tick — drains queued events and applies streaming text. */
|
||||
function handleTick(state: AppStateRest): void {
|
||||
// Streaming updates are applied via appendToLastTranscript by the turn
|
||||
// driver; here we only reconcile cache validity and progress the spinner.
|
||||
state.misc.tickCount += 1;
|
||||
markDirty(state);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Slash-command parser that maps TUI `/foo` input lines into `Command`
|
||||
* variants for the action dispatch system. Mirrors
|
||||
* `apps/interfaces/tui/src/controller/command.rs`.
|
||||
*/
|
||||
|
||||
import type { Action } from "./action.ts";
|
||||
|
||||
/** A parsed slash command from the TUI input buffer. */
|
||||
export type Command =
|
||||
| { tag: "Help" }
|
||||
| { tag: "Quit" }
|
||||
| { tag: "McpOpen" }
|
||||
| { tag: "Clear" }
|
||||
| { tag: "ClearConfirm" }
|
||||
| { tag: "Login"; provider: string }
|
||||
| { tag: "Edit"; path: string }
|
||||
| { tag: "McpAdd"; name: string; command: string }
|
||||
| { tag: "ModelList" }
|
||||
| { tag: "Compact" }
|
||||
| { tag: "TodoOpen" }
|
||||
| { tag: "PlanOpen" }
|
||||
| { tag: "UsageOpen" }
|
||||
| { tag: "Diff" }
|
||||
| { tag: "Unknown"; text: string };
|
||||
|
||||
/** Parse a slash-prefixed input line into a `Command` value. */
|
||||
export function parseCommand(text: string): Command {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
||||
|
||||
const parts = trimmed.split(" ", 3);
|
||||
const cmd = parts[0] ?? "";
|
||||
const arg1 = parts[1] ?? "";
|
||||
const arg2 = parts[2] ?? "";
|
||||
|
||||
switch (cmd) {
|
||||
case "/help":
|
||||
return { tag: "Help" };
|
||||
case "/quit":
|
||||
return { tag: "Quit" };
|
||||
case "/clear":
|
||||
return arg1 === "" ? { tag: "ClearConfirm" } : { tag: "Clear" };
|
||||
case "/login":
|
||||
return arg1 === "" ? { tag: "Login", provider: "" } : { tag: "Login", provider: arg1 };
|
||||
case "/edit":
|
||||
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
||||
case "/mcp":
|
||||
if (arg1 === "") return { tag: "McpOpen" };
|
||||
if (arg1 === "add") {
|
||||
const rest = arg2.trim();
|
||||
const space = rest.indexOf(" ");
|
||||
if (space !== -1) {
|
||||
return { tag: "McpAdd", name: rest.slice(0, space), command: rest.slice(space + 1).trim() };
|
||||
}
|
||||
return { tag: "McpAdd", name: rest, command: "" };
|
||||
}
|
||||
return { tag: "Unknown", text: cmd };
|
||||
case "/model":
|
||||
return { tag: "ModelList" };
|
||||
case "/compact":
|
||||
return { tag: "Compact" };
|
||||
case "/todo":
|
||||
return { tag: "TodoOpen" };
|
||||
case "/plan":
|
||||
return { tag: "PlanOpen" };
|
||||
case "/usage":
|
||||
return { tag: "UsageOpen" };
|
||||
case "/diff":
|
||||
return { tag: "Diff" };
|
||||
default:
|
||||
return { tag: "Unknown", text: cmd };
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a parsed `Command` into `Action` values for the event loop. */
|
||||
export function applyCommand(cmd: Command): Action[] {
|
||||
switch (cmd.tag) {
|
||||
case "Help":
|
||||
return [{ tag: "OpenOverlay", overlay: "help" }];
|
||||
case "Quit":
|
||||
return [{ tag: "QuitConfirm" }];
|
||||
case "McpOpen":
|
||||
return [{ tag: "OpenOverlay", overlay: "mcp" }];
|
||||
case "Clear":
|
||||
return [{ tag: "SystemNote", kind: "clear", message: "Transcript cleared." }];
|
||||
case "ClearConfirm":
|
||||
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
||||
case "Login":
|
||||
return [{ tag: "StartOAuth", provider: cmd.provider }];
|
||||
case "Edit":
|
||||
return [{ tag: "OpenEditor", path: cmd.path }];
|
||||
case "McpAdd":
|
||||
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
|
||||
case "ModelList":
|
||||
return [{ tag: "ModelList" }];
|
||||
case "Compact":
|
||||
return [{ tag: "Compact" }];
|
||||
case "TodoOpen":
|
||||
return [{ tag: "OpenOverlay", overlay: "todo" }];
|
||||
case "PlanOpen":
|
||||
return [{ tag: "OpenOverlay", overlay: "plan" }];
|
||||
case "UsageOpen":
|
||||
return [{ tag: "OpenOverlay", overlay: "usage" }];
|
||||
case "Diff":
|
||||
return [{ tag: "ShowDiff" }];
|
||||
case "Unknown":
|
||||
if (cmd.text.startsWith("/")) {
|
||||
return [{ tag: "SystemNote", kind: "error", message: `Unknown command: ${cmd.text}` }];
|
||||
}
|
||||
return [{ tag: "SubmitInput", text: cmd.text }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Key event dispatcher: maps raw key events into `Action` variants, with
|
||||
* special handling for overlays, autocomplete, and the inline editor.
|
||||
* Mirrors `apps/interfaces/tui/src/controller/input.rs`.
|
||||
*
|
||||
* Flow: `handleKey` is called on each key press → returns `Action[]`.
|
||||
*/
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import type { AppStateRest } from "./state.ts";
|
||||
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
|
||||
import type { Action } from "./action.ts";
|
||||
import { applyCommand, parseCommand } from "./command.ts";
|
||||
|
||||
/** A decoded terminal key event (normalised from raw input). */
|
||||
export interface KeyEvent {
|
||||
code: string;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift?: boolean;
|
||||
/** For plain char events, the decoded character(s). */
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** Common key codes we normalise from raw input. */
|
||||
export const KeyCode = {
|
||||
Enter: "Enter",
|
||||
Backspace: "Backspace",
|
||||
Delete: "Delete",
|
||||
Left: "ArrowLeft",
|
||||
Right: "ArrowRight",
|
||||
Up: "ArrowUp",
|
||||
Down: "ArrowDown",
|
||||
PageUp: "PageUp",
|
||||
PageDown: "PageDown",
|
||||
Esc: "Escape",
|
||||
Tab: "Tab",
|
||||
Char: "Char",
|
||||
} as const;
|
||||
|
||||
/** Normalise a raw string key (from raw-mode stdin) into a `KeyEvent`. */
|
||||
export function decodeKey(raw: string): KeyEvent | null {
|
||||
if (raw === "\r" || raw === "\n") return { code: KeyCode.Enter, ctrl: false, alt: false };
|
||||
if (raw === "\u001b") return { code: KeyCode.Esc, ctrl: false, alt: false };
|
||||
if (raw === "\t") return { code: KeyCode.Tab, ctrl: false, alt: false };
|
||||
if (raw === "\u007f" || raw === "\b") return { code: KeyCode.Backspace, ctrl: false, alt: false };
|
||||
|
||||
// Ctrl+letter (0x01–0x1a)
|
||||
const ch = raw.charCodeAt(0);
|
||||
if (ch >= 0x01 && ch <= 0x1a && raw.length === 1) {
|
||||
const letter = String.fromCharCode(ch + 0x60);
|
||||
return { code: KeyCode.Char, value: letter, ctrl: true, alt: false };
|
||||
}
|
||||
|
||||
// ESC-prefixed sequences: arrows, page keys, alt+char.
|
||||
if (raw.startsWith("\u001b[")) {
|
||||
switch (raw) {
|
||||
case "\u001b[A":
|
||||
return { code: KeyCode.Up, ctrl: false, alt: false };
|
||||
case "\u001b[B":
|
||||
return { code: KeyCode.Down, ctrl: false, alt: false };
|
||||
case "\u001b[C":
|
||||
return { code: KeyCode.Right, ctrl: false, alt: false };
|
||||
case "\u001b[D":
|
||||
return { code: KeyCode.Left, ctrl: false, alt: false };
|
||||
case "\u001b[5~":
|
||||
return { code: KeyCode.PageUp, ctrl: false, alt: false };
|
||||
case "\u001b[6~":
|
||||
return { code: KeyCode.PageDown, ctrl: false, alt: false };
|
||||
case "\u001b[3~":
|
||||
return { code: KeyCode.Delete, ctrl: false, alt: false };
|
||||
case "\u001b[1;5A":
|
||||
return { code: KeyCode.Up, ctrl: true, alt: false };
|
||||
case "\u001b[1;5B":
|
||||
return { code: KeyCode.Down, ctrl: true, alt: false };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (raw.startsWith("\u001b") && raw.length > 1) {
|
||||
// Alt+<char>
|
||||
const inner = raw.slice(1);
|
||||
if (inner.length === 1) {
|
||||
return { code: KeyCode.Char, value: inner, ctrl: false, alt: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Plain printable char(s)
|
||||
return { code: KeyCode.Char, value: raw, ctrl: false, alt: false };
|
||||
}
|
||||
|
||||
/** Mark state dirty and return an empty action list. */
|
||||
function mark(state: AppStateRest): Action[] {
|
||||
markDirty(state);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a decoded `KeyEvent` into zero or more `Action` values based on
|
||||
* the current application state.
|
||||
*/
|
||||
export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
const code = key.code;
|
||||
|
||||
// ── Editor overlay ───────────────────────────────────────────────────
|
||||
if (state.misc.overlay === "editor") {
|
||||
if (code === KeyCode.Char && key.ctrl && (key.value === "c" || key.value === "s")) {
|
||||
return [{ tag: "QuitConfirm" }];
|
||||
}
|
||||
if (code === KeyCode.Esc) {
|
||||
state.misc.editor = null;
|
||||
state.misc.overlay = "none";
|
||||
return [];
|
||||
}
|
||||
if (code === KeyCode.Backspace) {
|
||||
if (state.misc.editor) {
|
||||
state.misc.editor.deleteLeft();
|
||||
mark(state);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (code === KeyCode.Enter) {
|
||||
if (state.misc.editor) {
|
||||
state.misc.editor.content =
|
||||
state.misc.editor.content.slice(0, state.misc.editor.cursor) +
|
||||
"\n" +
|
||||
state.misc.editor.content.slice(state.misc.editor.cursor);
|
||||
state.misc.editor.cursor += 1;
|
||||
mark(state);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (code === KeyCode.Char && key.value) {
|
||||
if (state.misc.editor) {
|
||||
state.misc.editor.content =
|
||||
state.misc.editor.content.slice(0, state.misc.editor.cursor) +
|
||||
key.value +
|
||||
state.misc.editor.content.slice(state.misc.editor.cursor);
|
||||
state.misc.editor.cursor += key.value.length;
|
||||
mark(state);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Diff overlay ─────────────────────────────────────────────────────
|
||||
if (state.misc.overlay === "diff") {
|
||||
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||
if (code === KeyCode.Up || (code === KeyCode.Char && key.value === "k")) {
|
||||
return [{ tag: "DiffScroll", amount: -1 }];
|
||||
}
|
||||
if (code === KeyCode.Down || (code === KeyCode.Char && key.value === "j")) {
|
||||
return [{ tag: "DiffScroll", amount: 1 }];
|
||||
}
|
||||
if (code === KeyCode.PageUp) return [{ tag: "DiffScroll", amount: -20 }];
|
||||
if (code === KeyCode.PageDown) return [{ tag: "DiffScroll", amount: 20 }];
|
||||
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Learning overlay ─────────────────────────────────────────────────
|
||||
if (state.misc.overlay === "learning") {
|
||||
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
|
||||
if (code === KeyCode.Up) {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, false);
|
||||
return mark(state);
|
||||
}
|
||||
if (code === KeyCode.Down) {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, true);
|
||||
return mark(state);
|
||||
}
|
||||
if (code === KeyCode.Enter || (code === KeyCode.Char && key.value === "a")) {
|
||||
return [{ tag: "LessonAccept", name: `lesson-${state.misc.selectedIndex}` }];
|
||||
}
|
||||
if (code === KeyCode.Char && key.value === "r") {
|
||||
return [{ tag: "LessonReject", name: `lesson-${state.misc.selectedIndex}` }];
|
||||
}
|
||||
if (code === KeyCode.Char && key.value === "d") {
|
||||
return [{ tag: "LessonDelete", name: `lesson-${state.misc.selectedIndex}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||
switch (code) {
|
||||
case KeyCode.Char:
|
||||
if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||
if (key.ctrl && key.value === "d") return [{ tag: "CloseOverlay" }];
|
||||
if (key.ctrl && key.value === "y") {
|
||||
const lastAssistant = [...state.transcriptCache.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === Roles.Assistant);
|
||||
if (lastAssistant) {
|
||||
state.misc.pendingClipboardCopy = lastAssistant.content;
|
||||
} else {
|
||||
toastInfo(state, "No assistant message to copy yet");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (key.alt) return [{ tag: "ShowDiff" }];
|
||||
// Regular char insertion
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.closeAutocomplete();
|
||||
mark(state);
|
||||
}
|
||||
if (key.value) state.input.insert(key.value);
|
||||
mark(state);
|
||||
if (state.input.buffer.startsWith("/")) {
|
||||
state.input.openAutocomplete();
|
||||
} else if (state.input.mentionQueryAtCursor()) {
|
||||
state.input.openMentionAutocomplete(state.mentionFiles);
|
||||
}
|
||||
return [];
|
||||
case KeyCode.Enter:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.selectAutocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
if (overlayIsActive(state.misc.overlay)) {
|
||||
return []; // handle_overlay_enter (simplified: no-op)
|
||||
}
|
||||
{
|
||||
const text = state.input.buffer;
|
||||
if (text.startsWith("/")) {
|
||||
return applyCommand(parseCommand(text));
|
||||
}
|
||||
return [{ tag: "SubmitInput", text }];
|
||||
}
|
||||
case KeyCode.Backspace:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.closeAutocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
return [{ tag: "DeleteChar" }];
|
||||
case KeyCode.Delete:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.closeAutocomplete();
|
||||
return mark(state);
|
||||
}
|
||||
return [{ tag: "DeleteCharRight" }];
|
||||
case KeyCode.Left:
|
||||
return [{ tag: "CursorLeft" }];
|
||||
case KeyCode.Right:
|
||||
return [{ tag: "CursorRight" }];
|
||||
case KeyCode.Up:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.cycleAutocomplete(false);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (state.misc.overlay === "model_selector") {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(
|
||||
state.misc.selectedIndex,
|
||||
Object.keys(state.appConfig.providers).length,
|
||||
false,
|
||||
);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (key.ctrl) {
|
||||
return [{ tag: "ScrollUp" }];
|
||||
} else {
|
||||
return [{ tag: "HistoryUp" }];
|
||||
}
|
||||
case KeyCode.Down:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.cycleAutocomplete(true);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (state.misc.overlay === "model_selector") {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(
|
||||
state.misc.selectedIndex,
|
||||
Object.keys(state.appConfig.providers).length,
|
||||
true,
|
||||
);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (key.ctrl) {
|
||||
return [{ tag: "ScrollDown" }];
|
||||
} else {
|
||||
return [{ tag: "HistoryDown" }];
|
||||
}
|
||||
case KeyCode.PageUp:
|
||||
return [{ tag: "ScrollUp" }];
|
||||
case KeyCode.PageDown:
|
||||
return [{ tag: "ScrollDown" }];
|
||||
case KeyCode.Esc:
|
||||
if (turnInFlightLocal(state)) return [{ tag: "AbortTurn" }];
|
||||
else if (state.input.autocompleteVisible) {
|
||||
state.input.closeAutocomplete();
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (overlayIsActive(state.misc.overlay)) {
|
||||
return [{ tag: "CloseOverlay" }];
|
||||
}
|
||||
return [];
|
||||
case KeyCode.Tab:
|
||||
if (state.input.buffer.startsWith("/")) {
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.cycleAutocomplete(true);
|
||||
} else {
|
||||
state.input.tabComplete();
|
||||
}
|
||||
mark(state);
|
||||
} else if (
|
||||
state.input.autocompleteKind === AutocompleteKind.FileMention &&
|
||||
state.input.autocompleteVisible
|
||||
) {
|
||||
state.input.cycleAutocomplete(true);
|
||||
mark(state);
|
||||
}
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Local helper: whether an agent turn is in flight. */
|
||||
function turnInFlightLocal(state: AppStateRest): boolean {
|
||||
return state.turnInFlightFlag.value;
|
||||
}
|
||||
|
||||
/** Cycle a selected index within `n` items (forward or backward). */
|
||||
export function cycleSelectedIndex(idx: number, n: number, forward: boolean): number {
|
||||
if (n <= 0) return 0;
|
||||
if (forward) {
|
||||
return (idx + 1) % n;
|
||||
}
|
||||
return idx === 0 ? n - 1 : idx - 1;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Git diff preview helper — shells out to `git diff` over the workspace
|
||||
* roots and returns the combined output text. Mirrors
|
||||
* `apps/interfaces/tui/src/action/git.rs`.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/** Run `git diff` across the given workspace roots and return the output. */
|
||||
export function gitDiffOutput(workspaceRoots: string[]): string {
|
||||
for (const root of workspaceRoots) {
|
||||
try {
|
||||
const out = execSync("git diff --stat && git diff", {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (out.trim().length > 0) {
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or git not available — continue to the next root.
|
||||
}
|
||||
}
|
||||
return "No diff available (not a git repository).";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Zesdex TUI interface — logic-pure interactive layer + lightweight renderer.
|
||||
* Mirrors `apps/interfaces/tui/`.
|
||||
*/
|
||||
export type { Action } from "./action.ts";
|
||||
export { applyAction } from "./action.ts";
|
||||
export type { Command } from "./command.ts";
|
||||
export { parseCommand, applyCommand } from "./command.ts";
|
||||
export type { KeyEvent } from "./controller.ts";
|
||||
export { handleKey, decodeKey, cycleSelectedIndex } from "./controller.ts";
|
||||
export {
|
||||
createTuiState,
|
||||
AutocompleteKind,
|
||||
Overlay,
|
||||
COMMANDS,
|
||||
DEFAULT_HELP_TEXT,
|
||||
TranscriptCache,
|
||||
InputState,
|
||||
EditorState,
|
||||
ScrollState,
|
||||
MiscState,
|
||||
pushTranscript,
|
||||
appendToLastTranscript,
|
||||
makeChatMessage,
|
||||
toastInfo,
|
||||
toastError,
|
||||
turnInFlight,
|
||||
} from "./state.ts";
|
||||
export type { AppStateRest, ChatMessageDisplay } from "./state.ts";
|
||||
export { renderFrame } from "./render.ts";
|
||||
export { gitDiffOutput } from "./git.ts";
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex TUI — standalone entry point.
|
||||
* Mirrors the default (TUI) mode of the Rust gateway.
|
||||
*/
|
||||
import { runTui } from "./run.ts";
|
||||
|
||||
void runTui();
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Lightweight line-based terminal renderer for the TUI. Renders the
|
||||
* `AppStateRest` transcript, input line, overlay, and status into an array of
|
||||
* plain-text + ANSI escaped lines. No external ncurses dependency — outputs
|
||||
* raw ANSI sequences that work in any terminal. Conceptually mirrors
|
||||
* `apps/interfaces/tui/src/view/**` but simplified to line output.
|
||||
*/
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import type { AppStateRest } from "./state.ts";
|
||||
import { Overlay } from "./state.ts";
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
[Roles.User]: "you",
|
||||
[Roles.Assistant]: "zesdex",
|
||||
[Roles.System]: "system",
|
||||
[Roles.Tool]: "tool",
|
||||
};
|
||||
|
||||
/** Render the full TUI frame into an array of lines (with ANSI colours). */
|
||||
export function renderFrame(state: AppStateRest, height: number, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header bar.
|
||||
lines.push(renderHeader(state, width));
|
||||
|
||||
// Transcript pane.
|
||||
const transcriptLines = renderTranscript(state, width);
|
||||
lines.push(...transcriptLines);
|
||||
|
||||
// Padding to fill remaining height.
|
||||
const used = lines.length;
|
||||
for (let i = used; i < height - 3; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Overlay content (if any) replaces the input area.
|
||||
if (state.misc.overlay !== Overlay.None) {
|
||||
lines.push(renderOverlay(state, width));
|
||||
} else {
|
||||
lines.push(renderInput(state, width));
|
||||
}
|
||||
|
||||
// Status/footer bar.
|
||||
lines.push(renderStatus(state, width));
|
||||
|
||||
return lines.slice(0, height);
|
||||
}
|
||||
|
||||
function renderHeader(_state: AppStateRest, width: number): string {
|
||||
const title = " zesdex ";
|
||||
const pad = Math.max(0, width - title.length);
|
||||
return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`;
|
||||
}
|
||||
|
||||
function renderTranscript(state: AppStateRest, width: number): string[] {
|
||||
const out: string[] = [];
|
||||
const messages = state.transcriptCache.messages;
|
||||
// Honour scroll offset from the bottom like a typical chat TUI.
|
||||
const start = Math.max(0, messages.length - 30 - state.scroll.offset);
|
||||
for (let i = start; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
if (!m) continue;
|
||||
const label = (roleLabel[m.role] ?? m.role).padEnd(8);
|
||||
const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m";
|
||||
out.push(`${color}${label}\x1b[0m ${m.content}`);
|
||||
if (m.reasoning) {
|
||||
out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
void width;
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderInput(state: AppStateRest, width: number): string {
|
||||
const prompt = "> ";
|
||||
const shown = state.input.buffer.length > width - prompt.length
|
||||
? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length))
|
||||
: state.input.buffer;
|
||||
let line = `\x1b[1m${prompt}\x1b[0m${shown}`;
|
||||
// Append autocomplete hint if visible.
|
||||
if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) {
|
||||
const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx];
|
||||
line += ` \x1b[2m[tab: ${cand}]\x1b[0m`;
|
||||
}
|
||||
// Toasts.
|
||||
for (const t of state.misc.toasts.slice(-2)) {
|
||||
line += ` \x1b[3m(${t.message})\x1b[0m`;
|
||||
}
|
||||
return line.length > width ? line.slice(0, width) : line;
|
||||
}
|
||||
|
||||
function renderOverlay(state: AppStateRest, width: number): string {
|
||||
const name = state.misc.overlay;
|
||||
if (name === "diff") {
|
||||
const diffLines = state.misc.diffContent.split("\n");
|
||||
const from = state.misc.diffScroll;
|
||||
return diffLines.slice(from, from + 10).join("\n ");
|
||||
}
|
||||
if (name === "quit_confirm") {
|
||||
return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m";
|
||||
}
|
||||
if (name === "help") {
|
||||
return state.helpText;
|
||||
}
|
||||
void width;
|
||||
return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`;
|
||||
}
|
||||
|
||||
function renderStatus(state: AppStateRest, width: number): string {
|
||||
const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`;
|
||||
const right = state.turnInFlightFlag.value
|
||||
? " \x1b[5m● running\x1b[0m"
|
||||
: " \x1b[2m● idle\x1b[0m";
|
||||
const combined = `${left}${right}`;
|
||||
return combined.length > width ? combined.slice(0, width) : combined;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex TUI — interactive event loop.
|
||||
*
|
||||
* Reads keys from the terminal in raw mode, decodes them into `KeyEvent`s,
|
||||
* routes through `handleKey` → `applyAction`, and re-renders the frame.
|
||||
* Streaming agent output is applied to the transcript via
|
||||
* `appendToLastTranscript`.
|
||||
*
|
||||
* Mirrors the conceptual flow of `apps/interfaces/tui/src/run.rs` (event
|
||||
* loop + turn drain) using a plain raw-mode stdin reader instead of
|
||||
* crossterm/ratatui.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import { createTuiState, pushTranscript, appendToLastTranscript, makeChatMessage } from "./state.ts";
|
||||
import { applyAction } from "./action.ts";
|
||||
import type { Action } from "./action.ts";
|
||||
import { decodeKey, handleKey } from "./controller.ts";
|
||||
import type { KeyEvent } from "./controller.ts";
|
||||
import { renderFrame } from "./render.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
|
||||
function readRaw(): () => void {
|
||||
const prev = { ...(process.stdout as unknown as { attrs?: unknown }) };
|
||||
const stdin = process.stdin as unknown as { setRawMode: (b: boolean) => void };
|
||||
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
|
||||
// Show cursor-off control sequences are handled at frame draw.
|
||||
void prev;
|
||||
return () => {
|
||||
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
|
||||
process.stdout.write("\x1b[?25h\x1b[0m"); // show cursor + reset
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the interactive TUI until the user quits. */
|
||||
export async function runTui(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
|
||||
const sessionDir = `${dataDir}/sessions/${Date.now()}`;
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`);
|
||||
const restore = readRaw();
|
||||
|
||||
// Seed some transcript so the pane isn't empty on first render.
|
||||
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
|
||||
|
||||
const stdin = process.stdin as unknown as {
|
||||
setRawMode: (b: boolean) => void;
|
||||
on: (ev: string, cb: (chunk: Buffer) => void) => void;
|
||||
resume: () => void;
|
||||
};
|
||||
stdin.resume();
|
||||
|
||||
let buffer = "";
|
||||
stdin.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
// Process complete escape sequences (each is self-contained).
|
||||
while (buffer.length > 0) {
|
||||
const ev = decodeKey(buffer);
|
||||
if (!ev) {
|
||||
// Might be a partial escape sequence; wait for more bytes.
|
||||
break;
|
||||
}
|
||||
// consume the decoded sequence length
|
||||
const consumed = consumedLen(buffer, ev);
|
||||
buffer = buffer.slice(consumed);
|
||||
dispatch(ev, state);
|
||||
}
|
||||
draw(state);
|
||||
});
|
||||
|
||||
// Turn drain: poll a fake "in-flight" by checking pendingSubmit periodically.
|
||||
const drainTimer = setInterval(() => {
|
||||
if (state.pendingSubmit !== null) {
|
||||
const text = state.pendingSubmit;
|
||||
state.pendingSubmit = null;
|
||||
state.turnInFlightFlag.value = true;
|
||||
state.dirty = true;
|
||||
// With no live LLM wiring in the pure TUI, simulate a short "thinking"
|
||||
// then append a fixed assistant note. Real wiring happens in the CLI.
|
||||
setTimeout(() => {
|
||||
state.turnInFlightFlag.value = false;
|
||||
appendToLastTranscript(state, `(no LLM configured — processed: "${text}")`, false);
|
||||
state.dirty = true;
|
||||
draw(state);
|
||||
}, 50);
|
||||
}
|
||||
if (state.dirty) draw(state);
|
||||
}, 100);
|
||||
|
||||
draw(state);
|
||||
|
||||
// Block until quit.
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (state.quit) {
|
||||
clearInterval(check);
|
||||
clearInterval(drainTimer);
|
||||
restore();
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/** Determine how many characters of the input buffer a decoded event consumed. */
|
||||
function consumedLen(buffer: string, _ev: KeyEvent): number {
|
||||
// Escape sequences are multi-byte; a plain char is 1 codepoint.
|
||||
if (buffer.startsWith("\u001b[")) {
|
||||
// consume through the terminating letter (~ or A-D).
|
||||
const m = buffer.match(/^\u001b\[([0-9;]*)([A-Z~])/);
|
||||
return m ? m[0].length : buffer.length;
|
||||
}
|
||||
if (buffer.startsWith("\u001b")) {
|
||||
return buffer.length; // alt sequence
|
||||
}
|
||||
return [...buffer][0]!.length;
|
||||
}
|
||||
|
||||
/** Dispatch a decoded key via the controller and apply resulting actions. */
|
||||
function dispatch(key: KeyEvent, state: ReturnType<typeof createTuiState>): void {
|
||||
const actions = handleKey(key, state);
|
||||
for (const action of actions) {
|
||||
const act = action as Action;
|
||||
if (act.tag === "QuitConfirm") {
|
||||
// Go straight to quit on Ctrl+C (simplified confirm).
|
||||
state.quit = true;
|
||||
continue;
|
||||
}
|
||||
applyAction(state, act);
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw the current frame. */
|
||||
function draw(state: ReturnType<typeof createTuiState>): void {
|
||||
if (!state.dirty) return;
|
||||
state.dirty = false;
|
||||
const h = process.stdout.rows ?? 24;
|
||||
const w = process.stdout.columns ?? 80;
|
||||
const lines = renderFrame(state, h, w);
|
||||
process.stdout.write("\x1b[2J\x1b[H"); // clear + home
|
||||
process.stdout.write(lines.join("\n"));
|
||||
process.stdout.write("\x1b[?25h");
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* TUI state — the single source-of-truth struct for the TUI interface plus
|
||||
* all the types it owns. Mirrors `apps/interfaces/tui/src/state/**`.
|
||||
*
|
||||
* Design: `AppStateRest` is mutated by `applyAction` and read by the renderer
|
||||
* each frame. Everything here is logic-pure (no terminal I/O), so it is
|
||||
* unit-testable in Bun.
|
||||
*/
|
||||
|
||||
import { Roles, type Role } from "@zesdex/domain";
|
||||
|
||||
/* ── Input state ──────────────────────────────────────────────────── */
|
||||
|
||||
/** Which source populated the autocomplete dropdown. */
|
||||
export const AutocompleteKind = {
|
||||
Command: "command",
|
||||
FileMention: "file_mention",
|
||||
} as const;
|
||||
export type AutocompleteKind = (typeof AutocompleteKind)[keyof typeof AutocompleteKind];
|
||||
|
||||
/** Builtin slash-commands recognised by the chat input autocomplete. */
|
||||
export const COMMANDS: string[] = [
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
/** The user's input buffer, cursor, history, and autocomplete state. */
|
||||
export class InputState {
|
||||
buffer = "";
|
||||
cursor = 0;
|
||||
history: string[] = [];
|
||||
historyIdx: number | null = null;
|
||||
autocompleteCandidates: string[] = [];
|
||||
autocompleteIdx = 0;
|
||||
autocompleteVisible = false;
|
||||
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
||||
mentionStart = 0;
|
||||
historyFile: string | null = null;
|
||||
|
||||
/** Hide the autocomplete dropdown and clear its state. */
|
||||
closeAutocomplete(): void {
|
||||
this.autocompleteVisible = false;
|
||||
this.autocompleteCandidates = [];
|
||||
this.autocompleteIdx = 0;
|
||||
this.autocompleteKind = AutocompleteKind.Command;
|
||||
this.mentionStart = 0;
|
||||
}
|
||||
|
||||
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
||||
openAutocomplete(): void {
|
||||
const trimmed = this.buffer.trim();
|
||||
if (trimmed.length === 0 || !trimmed.startsWith("/")) {
|
||||
this.closeAutocomplete();
|
||||
return;
|
||||
}
|
||||
const prefix = trimmed.toLowerCase();
|
||||
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix));
|
||||
this.autocompleteKind = AutocompleteKind.Command;
|
||||
this.autocompleteIdx = 0;
|
||||
this.autocompleteVisible = this.autocompleteCandidates.length > 0;
|
||||
}
|
||||
|
||||
/** Find the `@mention` token (if any) immediately before the cursor. */
|
||||
mentionQueryAtCursor(): [number, string] | null {
|
||||
const beforeCursor = this.buffer.slice(0, this.cursor);
|
||||
const atPos = beforeCursor.lastIndexOf("@");
|
||||
if (atPos === -1) return null;
|
||||
const between = beforeCursor.slice(atPos + 1);
|
||||
if (/[\s]/.test(between)) return null;
|
||||
const boundaryOk =
|
||||
atPos === 0 || /[\s]/.test(beforeCursor[atPos - 1] ?? "") || /\s/.test(beforeCursor[atPos - 1] ?? "");
|
||||
if (!boundaryOk) return null;
|
||||
return [atPos, between];
|
||||
}
|
||||
|
||||
/** Open or refresh the `@file` mention dropdown from `files`. */
|
||||
openMentionAutocomplete(files: string[]): void {
|
||||
const q = this.mentionQueryAtCursor();
|
||||
if (!q) {
|
||||
this.closeAutocomplete();
|
||||
return;
|
||||
}
|
||||
const [, query] = q;
|
||||
const ql = query.toLowerCase();
|
||||
// Simple substring + prefix fuzzy match over the file list.
|
||||
const matched = files
|
||||
.filter((f) => {
|
||||
const fl = f.toLowerCase();
|
||||
return fl.includes(ql) || f.replace(/.*\//, "").toLowerCase().startsWith(ql);
|
||||
})
|
||||
.slice(0, 10);
|
||||
this.autocompleteCandidates = matched;
|
||||
this.autocompleteKind = AutocompleteKind.FileMention;
|
||||
this.mentionStart = q[0];
|
||||
this.autocompleteIdx = 0;
|
||||
this.autocompleteVisible = matched.length > 0;
|
||||
}
|
||||
|
||||
/** Move the autocomplete selection up (false) or down (true). */
|
||||
cycleAutocomplete(forward: boolean): void {
|
||||
const n = this.autocompleteCandidates.length;
|
||||
if (n === 0) return;
|
||||
if (forward) {
|
||||
this.autocompleteIdx = (this.autocompleteIdx + 1) % n;
|
||||
} else {
|
||||
this.autocompleteIdx =
|
||||
this.autocompleteIdx === 0 ? n - 1 : this.autocompleteIdx - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept the currently selected autocomplete candidate. Returns true on success. */
|
||||
selectAutocomplete(): boolean {
|
||||
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
||||
if (candidate === undefined || candidate === null) return false;
|
||||
if (this.autocompleteKind === AutocompleteKind.Command) {
|
||||
this.buffer = candidate;
|
||||
this.cursor = this.buffer.length;
|
||||
} else {
|
||||
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
||||
this.closeAutocomplete();
|
||||
return false;
|
||||
}
|
||||
const replacement = `@${candidate} `;
|
||||
this.buffer =
|
||||
this.buffer.slice(0, this.mentionStart) + replacement + this.buffer.slice(this.cursor);
|
||||
this.cursor = this.mentionStart + replacement.length;
|
||||
}
|
||||
this.closeAutocomplete();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tab-complete: open dropdown or cycle forward. */
|
||||
tabComplete(): void {
|
||||
if (this.autocompleteVisible) {
|
||||
this.cycleAutocomplete(true);
|
||||
} else {
|
||||
this.openAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a character at the cursor position. */
|
||||
insert(c: string): void {
|
||||
this.buffer = this.buffer.slice(0, this.cursor) + c + this.buffer.slice(this.cursor);
|
||||
this.cursor += c.length;
|
||||
}
|
||||
|
||||
/** Delete the character to the left of the cursor (backspace). */
|
||||
deleteLeft(): void {
|
||||
if (this.cursor > 0) {
|
||||
this.cursor -= 1;
|
||||
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the character at the cursor position (forward delete). */
|
||||
deleteRight(): void {
|
||||
if (this.cursor < this.buffer.length) {
|
||||
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit the current buffer and return the submitted text. */
|
||||
submit(): string {
|
||||
const result = this.buffer;
|
||||
if (result.length > 0) {
|
||||
if (this.history[this.history.length - 1] !== result) {
|
||||
this.history.push(result);
|
||||
}
|
||||
this.historyIdx = null;
|
||||
}
|
||||
this.buffer = "";
|
||||
this.cursor = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Navigate backward through input history. */
|
||||
historyUp(): void {
|
||||
if (this.history.length === 0) return;
|
||||
let idx: number;
|
||||
if (this.historyIdx !== null && this.historyIdx > 0) {
|
||||
idx = this.historyIdx - 1;
|
||||
} else if (this.historyIdx === null) {
|
||||
idx = this.history.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.historyIdx = idx;
|
||||
this.buffer = this.history[idx] ?? "";
|
||||
this.cursor = this.buffer.length;
|
||||
}
|
||||
|
||||
/** Navigate forward through input history. */
|
||||
historyDown(): void {
|
||||
if (this.historyIdx !== null && this.historyIdx < this.history.length - 1) {
|
||||
const idx = this.historyIdx + 1;
|
||||
this.historyIdx = idx;
|
||||
this.buffer = this.history[idx] ?? "";
|
||||
this.cursor = this.buffer.length;
|
||||
} else if (this.historyIdx !== null) {
|
||||
this.historyIdx = null;
|
||||
this.buffer = "";
|
||||
this.cursor = 0;
|
||||
}
|
||||
// else nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Overlay ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** Which modal overlay, if any, is currently shown over the main TUI view. */
|
||||
export const Overlay = {
|
||||
None: "none",
|
||||
Help: "help",
|
||||
Settings: "settings",
|
||||
Bash: "bash",
|
||||
QuitConfirm: "quit_confirm",
|
||||
KeyInput: "key_input",
|
||||
Editor: "editor",
|
||||
Effort: "effort",
|
||||
Mcp: "mcp",
|
||||
Todo: "todo",
|
||||
Plan: "plan",
|
||||
Rewind: "rewind",
|
||||
Learning: "learning",
|
||||
Usage: "usage",
|
||||
Loading: "loading",
|
||||
ModelSelector: "model_selector",
|
||||
ClearConfirm: "clear_confirm",
|
||||
Diff: "diff",
|
||||
} as const;
|
||||
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
|
||||
|
||||
export function overlayIsActive(overlay: Overlay): boolean {
|
||||
return overlay !== Overlay.None;
|
||||
}
|
||||
|
||||
/* ── Editor state ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Simple inline editor state for the TUI. */
|
||||
export class EditorState {
|
||||
constructor(
|
||||
public path: string,
|
||||
public content: string,
|
||||
public cursor: number = content.length,
|
||||
) {}
|
||||
|
||||
asString(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
deleteLeft(): void {
|
||||
if (this.cursor > 0) {
|
||||
this.cursor -= 1;
|
||||
this.content = this.content.slice(0, this.cursor) + this.content.slice(this.cursor + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Misc state ───────────────────────────────────────────────────── */
|
||||
|
||||
/** The "miscellaneous" slice of app state. */
|
||||
export class MiscState {
|
||||
overlay: Overlay = Overlay.None;
|
||||
toasts: { kind: string; message: string; createdMs: number; ttlMs: number }[] = [];
|
||||
lastStalenessSweepMs = 0;
|
||||
thinking = false;
|
||||
effortLevel = 1;
|
||||
selectedIndex = 0;
|
||||
editor: EditorState | null = null;
|
||||
apiConnected = false;
|
||||
tickCount = 0;
|
||||
todoContent = "";
|
||||
planContent = "";
|
||||
lessonRunning = false;
|
||||
pendingClipboardCopy: string | null = null;
|
||||
diffContent = "";
|
||||
diffScroll = 0;
|
||||
|
||||
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||
this.toasts.push(toast);
|
||||
}
|
||||
|
||||
drainExpiredToasts(nowMs: number): void {
|
||||
this.toasts = this.toasts.filter((t) => !(nowMs - t.createdMs >= t.ttlMs));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scroll state ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Viewport scroll state: current offset and visible-line count. */
|
||||
export class ScrollState {
|
||||
offset = 0;
|
||||
maxVisible = 30;
|
||||
|
||||
scrollUp(amount: number): void {
|
||||
this.offset += amount;
|
||||
}
|
||||
scrollDown(amount: number): void {
|
||||
this.offset = Math.max(0, this.offset - amount);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transcript ───────────────────────────────────────────────────── */
|
||||
|
||||
/** A single transcript entry rendered in the TUI chat pane. */
|
||||
export interface ChatMessageDisplay {
|
||||
role: Role;
|
||||
content: string;
|
||||
reasoning: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function makeChatMessage(role: Role, content: string): ChatMessageDisplay {
|
||||
return { role, content, reasoning: "", timestamp: Date.now() };
|
||||
}
|
||||
|
||||
/** Bounded ring of recent chat messages used to render the transcript view. */
|
||||
export class TranscriptCache {
|
||||
messages: ChatMessageDisplay[] = [];
|
||||
maxLines: number;
|
||||
dirty = true;
|
||||
|
||||
constructor(maxLines = 200) {
|
||||
this.maxLines = maxLines;
|
||||
}
|
||||
|
||||
push(msg: ChatMessageDisplay): void {
|
||||
this.messages.push(msg);
|
||||
while (this.messages.length > this.maxLines) {
|
||||
this.messages.shift();
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
appendToLast(text: string, isReasoning: boolean): void {
|
||||
const msg = this.messages[this.messages.length - 1];
|
||||
if (msg && msg.role === Roles.Assistant) {
|
||||
if (isReasoning) {
|
||||
msg.reasoning += text;
|
||||
} else {
|
||||
msg.content += text;
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── AppStateRest ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The single source-of-truth state struct for the TUI interface.
|
||||
* Mutated via `applyAction`; read by the renderer every frame.
|
||||
*/
|
||||
export interface AppStateRest {
|
||||
settings: Record<string, unknown>;
|
||||
appConfig: { providers: Record<string, { apiBase?: string; defaultApiKey?: string; apiKeyEnv?: string }> };
|
||||
workspaceRoots: string[];
|
||||
sessionId: string;
|
||||
sessionDir: string;
|
||||
memoryDir: string;
|
||||
worktreesDir: string;
|
||||
transcriptCache: TranscriptCache;
|
||||
scroll: ScrollState;
|
||||
input: InputState;
|
||||
misc: MiscState;
|
||||
turnInFlightFlag: { value: boolean };
|
||||
abortFlag: { value: boolean };
|
||||
dirty: boolean;
|
||||
quit: boolean;
|
||||
helpText: string;
|
||||
displayLinesCache: string[];
|
||||
cachedTokenCount: number;
|
||||
tokenCountDirty: boolean;
|
||||
lastRenderWidth: number;
|
||||
cachedMsgCount: number;
|
||||
cachedLastStart: number;
|
||||
cachedLastLen: number;
|
||||
renderWidthAtCache: number;
|
||||
mentionFiles: string[];
|
||||
pendingSubmit: string | null; // set when a turn should be spawned
|
||||
}
|
||||
|
||||
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||
|
||||
─── General ───
|
||||
Ctrl+C Quit confirm
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
|
||||
─── Navigation ───
|
||||
↑ / ↓ History browse / Overlay navigate
|
||||
Ctrl+↑/↓ Scroll transcript
|
||||
PgUp / PgDown Scroll transcript
|
||||
Enter Submit / Select autocomplete
|
||||
|
||||
─── Overlays ───
|
||||
/help Show this help
|
||||
/settings Open settings overlay
|
||||
/todo Open tasks (todo) overlay
|
||||
/usage Open usage statistics
|
||||
/bash Open bash jobs overlay
|
||||
/mcp Open MCP server management
|
||||
/model Open model selector
|
||||
/compact Compact conversation
|
||||
/clear Clear transcript
|
||||
/rewind Rewind conversation history
|
||||
|
||||
─── Editor Mode ───
|
||||
/edit <path> Open file for inline editing
|
||||
Ctrl+S Save changes
|
||||
Esc Dismiss editor`;
|
||||
|
||||
/** Construct fresh TUI state. */
|
||||
export function createTuiState(
|
||||
workspaceRoots: string[],
|
||||
sessionDir: string,
|
||||
memoryDir: string,
|
||||
): AppStateRest {
|
||||
const sessionId = sessionDir.split(/[/\\]/).filter(Boolean).pop() ?? "";
|
||||
const worktreesDir = memoryDir.replace(/[\\/][^\\/]*$/, "") + "/worktrees";
|
||||
return {
|
||||
settings: { provider: "claude" },
|
||||
appConfig: { providers: {} },
|
||||
workspaceRoots,
|
||||
sessionId,
|
||||
sessionDir,
|
||||
memoryDir,
|
||||
worktreesDir,
|
||||
transcriptCache: new TranscriptCache(200),
|
||||
scroll: new ScrollState(),
|
||||
input: new InputState(),
|
||||
misc: new MiscState(),
|
||||
turnInFlightFlag: { value: false },
|
||||
abortFlag: { value: false },
|
||||
dirty: true,
|
||||
quit: false,
|
||||
helpText: DEFAULT_HELP_TEXT,
|
||||
displayLinesCache: [],
|
||||
cachedTokenCount: 0,
|
||||
tokenCountDirty: true,
|
||||
lastRenderWidth: 0,
|
||||
cachedMsgCount: 0,
|
||||
cachedLastStart: 0,
|
||||
cachedLastLen: 0,
|
||||
renderWidthAtCache: 0,
|
||||
mentionFiles: [],
|
||||
pendingSubmit: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an agent turn is currently running. */
|
||||
export function turnInFlight(state: AppStateRest): boolean {
|
||||
return state.turnInFlightFlag.value;
|
||||
}
|
||||
|
||||
/** Append a message to the transcript. */
|
||||
export function pushTranscript(state: AppStateRest, msg: ChatMessageDisplay): void {
|
||||
state.transcriptCache.push(msg);
|
||||
state.tokenCountDirty = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/** Append text to the last assistant message. */
|
||||
export function appendToLastTranscript(state: AppStateRest, text: string, isReasoning: boolean): void {
|
||||
state.transcriptCache.appendToLast(text, isReasoning);
|
||||
if (state.transcriptCache.dirty) state.dirty = true;
|
||||
}
|
||||
|
||||
export function markDirty(state: AppStateRest): void {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
export function pushToast(
|
||||
state: AppStateRest,
|
||||
toast: { kind: string; message: string; createdMs: number; ttlMs: number },
|
||||
): void {
|
||||
state.misc.pushToast(toast);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
export function toastInfo(state: AppStateRest, message: string): void {
|
||||
pushToast(state, { kind: "info", message, createdMs: Date.now(), ttlMs: 4000 });
|
||||
}
|
||||
export function toastSuccess(state: AppStateRest, message: string): void {
|
||||
pushToast(state, { kind: "success", message, createdMs: Date.now(), ttlMs: 4000 });
|
||||
}
|
||||
export function toastWarning(state: AppStateRest, message: string): void {
|
||||
pushToast(state, { kind: "warning", message, createdMs: Date.now(), ttlMs: 4000 });
|
||||
}
|
||||
export function toastError(state: AppStateRest, message: string): void {
|
||||
pushToast(state, { kind: "error", message, createdMs: Date.now(), ttlMs: 5000 });
|
||||
}
|
||||
|
||||
/** Resolve the base directory of the store (grandparent of the session dir). */
|
||||
export function storeBaseDir(state: AppStateRest): string {
|
||||
return state.sessionDir.replace(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for the TUI logic layer: command parsing, action dispatch, and the
|
||||
* key controller. Mirrors the Rust `#[cfg(test)]` blocks.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseCommand, applyCommand } from "./command.ts";
|
||||
import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts";
|
||||
import { applyAction } from "./action.ts";
|
||||
import { handleKey, decodeKey } from "./controller.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
|
||||
function freshState() {
|
||||
return createTuiState(["/tmp/zesdex-tui-test"], "/tmp/zesdex-tui-test/sess", "/tmp/zesdex-tui-test/mem");
|
||||
}
|
||||
|
||||
describe("parseCommand", () => {
|
||||
test("recognises /todo", () => {
|
||||
expect(parseCommand("/todo")).toEqual({ tag: "TodoOpen" });
|
||||
});
|
||||
test("recognises /usage", () => {
|
||||
expect(parseCommand("/usage")).toEqual({ tag: "UsageOpen" });
|
||||
});
|
||||
test("non-slash input is Unknown", () => {
|
||||
expect(parseCommand("hello")).toEqual({ tag: "Unknown", text: "hello" });
|
||||
});
|
||||
test("parses /edit path", () => {
|
||||
expect(parseCommand("/edit src/a.ts")).toEqual({ tag: "Edit", path: "src/a.ts" });
|
||||
});
|
||||
test("parses /clear with arg as Clear, without as ClearConfirm", () => {
|
||||
expect(parseCommand("/clear foo")).toEqual({ tag: "Clear" });
|
||||
expect(parseCommand("/clear")).toEqual({ tag: "ClearConfirm" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCommand", () => {
|
||||
test("Help opens help overlay", () => {
|
||||
expect(applyCommand({ tag: "Help" })).toEqual([{ tag: "OpenOverlay", overlay: "help" }]);
|
||||
});
|
||||
test("Unknown slash produces SystemNote error", () => {
|
||||
expect(applyCommand({ tag: "Unknown", text: "/nope" })).toEqual([
|
||||
{ tag: "SystemNote", kind: "error", message: "Unknown command: /nope" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAction", () => {
|
||||
test("SubmitInput pushes user transcript and sets pendingSubmit", () => {
|
||||
const s = freshState();
|
||||
applyAction(s, { tag: "SubmitInput", text: "hi" });
|
||||
const last = s.transcriptCache.messages[s.transcriptCache.messages.length - 1];
|
||||
expect(last?.role).toBe(Roles.User);
|
||||
expect(last?.content).toBe("hi");
|
||||
expect(s.pendingSubmit).toBe("hi");
|
||||
});
|
||||
|
||||
test("ForceQuit sets quit", () => {
|
||||
const s = freshState();
|
||||
applyAction(s, { tag: "ForceQuit" });
|
||||
expect(s.quit).toBe(true);
|
||||
});
|
||||
|
||||
test("QuitConfirm sets overlay", () => {
|
||||
const s = freshState();
|
||||
applyAction(s, { tag: "QuitConfirm" });
|
||||
expect(s.misc.overlay).toBe("quit_confirm");
|
||||
});
|
||||
|
||||
test("AbortTurn sets abort flag", () => {
|
||||
const s = freshState();
|
||||
applyAction(s, { tag: "AbortTurn" });
|
||||
expect(s.abortFlag.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleKey", () => {
|
||||
test("Ctrl+C produces QuitConfirm", () => {
|
||||
const s = freshState();
|
||||
const actions = handleKey({ code: "Char", value: "c", ctrl: true, alt: false }, s);
|
||||
expect(actions).toEqual([{ tag: "QuitConfirm" }]);
|
||||
});
|
||||
|
||||
test("typing chars inserts into buffer", () => {
|
||||
const s = freshState();
|
||||
handleKey({ code: "Char", value: "h", ctrl: false, alt: false }, s);
|
||||
handleKey({ code: "Char", value: "i", ctrl: false, alt: false }, s);
|
||||
expect(s.input.buffer).toBe("hi");
|
||||
});
|
||||
|
||||
test("Enter submits input as SubmitInput", () => {
|
||||
const s = freshState();
|
||||
handleKey({ code: "Char", value: "a", ctrl: false, alt: false }, s);
|
||||
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]);
|
||||
});
|
||||
|
||||
test("Ctrl+Y copies last assistant message", () => {
|
||||
const s = freshState();
|
||||
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
|
||||
pushTranscript(s, makeChatMessage(Roles.Assistant, "first"));
|
||||
pushTranscript(s, makeChatMessage(Roles.Tool, "tool"));
|
||||
pushTranscript(s, makeChatMessage(Roles.Assistant, "second"));
|
||||
handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s);
|
||||
expect(s.misc.pendingClipboardCopy).toBe("second");
|
||||
});
|
||||
|
||||
test("Ctrl+Y with no assistant pushes info toast", () => {
|
||||
const s = freshState();
|
||||
handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s);
|
||||
expect(s.misc.pendingClipboardCopy).toBeNull();
|
||||
expect(s.misc.toasts.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeKey", () => {
|
||||
test("Enter decodes", () => {
|
||||
expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false });
|
||||
});
|
||||
test("arrow up decodes", () => {
|
||||
expect(decodeKey("\u001b[A")).toEqual({ code: "ArrowUp", ctrl: false, alt: false });
|
||||
});
|
||||
test("ctrl-c decodes to ctrl Char c", () => {
|
||||
expect(decodeKey("\u0003")).toEqual({ code: "Char", value: "c", ctrl: true, alt: false });
|
||||
});
|
||||
test("plain letter decodes", () => {
|
||||
expect(decodeKey("x")).toEqual({ code: "Char", value: "x", ctrl: false, alt: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Zesdex web frontend interface package.
|
||||
* Mirrors `apps/interfaces/web/`.
|
||||
*/
|
||||
export { startWebServer, handleWebRequest } from "./server.ts";
|
||||
export type { WebState } from "./server.ts";
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex web server — standalone entry point.
|
||||
* Mirrors the `--web` mode of the Rust gateway.
|
||||
*/
|
||||
import { startWebServer } from "./index.ts";
|
||||
|
||||
const port = Number.parseInt(process.env.ZESDEX_WEB_PORT ?? "3000", 10);
|
||||
const staticDir = process.env.ZESDEX_WEB_DIR ?? undefined;
|
||||
startWebServer(port, staticDir);
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex web frontend interface — serves static browser assets.
|
||||
* Mirrors `apps/interfaces/web/src/lib.rs`.
|
||||
*
|
||||
* Serves files from a `dist/`/static directory with path-traversal protection:
|
||||
* every requested path is canonicalized and must resolve inside `static_dir`.
|
||||
* Falls back to a minimal placeholder page when no frontend is built.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** Web server state. */
|
||||
export interface WebState {
|
||||
static_dir: string;
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
function mimeFor(file: string): string {
|
||||
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Placeholder page when no frontend is built. */
|
||||
const PLACEHOLDER = `<!DOCTYPE html>
|
||||
<html><head><title>Zesdex Web</title>
|
||||
<meta charset="utf-8">
|
||||
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
|
||||
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
|
||||
<body>
|
||||
<h1>Zesdex Web</h1>
|
||||
<p>Web interface is ready.</p>
|
||||
<p>To connect the frontend:</p>
|
||||
<ol>
|
||||
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
|
||||
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
|
||||
</ol>
|
||||
</body></html>`;
|
||||
|
||||
/** Resolve a requested path safely inside `staticDir`; returns null on traversal/not-found. */
|
||||
function safeResolve(staticDir: string, rel: string): string | null {
|
||||
// Strip any leading slash so a relative path is resolved against staticDir
|
||||
// (path.resolve would otherwise ignore staticDir for absolute-ish inputs).
|
||||
const clean = rel.replace(/^\/+/, "");
|
||||
const candidate = path.resolve(staticDir, clean || "index.html");
|
||||
const canonStatic = path.resolve(staticDir);
|
||||
// Prevent directory traversal: resolved path must stay inside static dir.
|
||||
if (!candidate.startsWith(canonStatic + path.sep) && candidate !== canonStatic) {
|
||||
return null;
|
||||
}
|
||||
// Only serve regular files; reject if the target is a directory or missing.
|
||||
try {
|
||||
const st = fs.statSync(candidate);
|
||||
return st.isFile() ? candidate : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the web router (static file serving). */
|
||||
export function handleWebRequest(state: WebState, req: Request): Response {
|
||||
const url = new URL(req.url);
|
||||
let rel = decodeURIComponent(url.pathname);
|
||||
if (rel.endsWith("/")) rel += "index.html";
|
||||
|
||||
const file = safeResolve(state.static_dir, rel);
|
||||
if (!file) {
|
||||
// Root always returns the placeholder index (even without a built frontend).
|
||||
if (rel === "/index.html") {
|
||||
return new Response(PLACEHOLDER, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(file);
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": mimeFor(file) },
|
||||
});
|
||||
}
|
||||
|
||||
/** Start the web frontend server. */
|
||||
export function startWebServer(port: number, staticDir?: string): { stop: () => void } {
|
||||
const dir =
|
||||
staticDir && staticDir !== ""
|
||||
? staticDir
|
||||
: (() => {
|
||||
const p = path.resolve("apps/interfaces/web/dist");
|
||||
return fs.existsSync(p) ? p : process.cwd();
|
||||
})();
|
||||
const state: WebState = { static_dir: dir };
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch(req) {
|
||||
return handleWebRequest(state, req);
|
||||
},
|
||||
});
|
||||
console.log(`zesdex-web listening on http://0.0.0.0:${server.port} (dir: ${state.static_dir})`);
|
||||
return { stop: () => server.stop(true) };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Zesdex WebSocket interface package.
|
||||
* Mirrors `apps/interfaces/ws/`.
|
||||
*/
|
||||
export { startWsServer } from "./server.ts";
|
||||
export type { WsState, SocketSend } from "./server.ts";
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex WS server — standalone entry point.
|
||||
* Mirrors the `--ws` mode of the Rust gateway.
|
||||
*/
|
||||
import { startWsServer } from "./index.ts";
|
||||
|
||||
const port = Number.parseInt(process.env.ZESDEX_WS_PORT ?? "8081", 10);
|
||||
startWsServer(port);
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex WebSocket interface — real-time bidirectional communication.
|
||||
* Mirrors `apps/interfaces/ws/src/lib.rs`.
|
||||
*
|
||||
* Security: accepts an optional `?token=` query param. When `ZESDEX_WS_TOKEN`
|
||||
* env is set, connections MUST present a matching token, else rejected —
|
||||
* prevents the endpoint from being used as an open LLM proxy.
|
||||
*
|
||||
* Protocol (JSON text frames):
|
||||
* client → { "type": "prompt", "message": "...", "model"?: "..." }
|
||||
* server → { "type": "connected", "session": ..., "message": "..." } (on connect)
|
||||
* server → { "type": "token", "content": "..." } (streaming)
|
||||
* server → { "type": "done" } | { "type": "error", "message": "..." } (terminal)
|
||||
* server → { "type": "echo", "data": "..." } (fallback)
|
||||
*/
|
||||
import {
|
||||
JsonSettingsRepository,
|
||||
JsonAppConfigRepository,
|
||||
LlmClient,
|
||||
resolveApiKey,
|
||||
InfrastructureToolExecutor,
|
||||
allTools,
|
||||
toolDefs,
|
||||
} from "@zesdex/infrastructure";
|
||||
import type { ToolCtx } from "@zesdex/infrastructure";
|
||||
import { AgentTurnServiceImpl } from "@zesdex/application";
|
||||
import {
|
||||
newStore,
|
||||
ensureStoreDirs,
|
||||
type TurnEvent,
|
||||
type TurnEventSink,
|
||||
type AgentTurnParams,
|
||||
userMessage,
|
||||
resolveEffectiveModel,
|
||||
} from "@zesdex/domain";
|
||||
|
||||
/** Shared application state for the WS server. */
|
||||
export interface WsState {
|
||||
store_base_dir: string;
|
||||
session_id: string | null;
|
||||
}
|
||||
|
||||
/** Minimal send interface used by streaming helpers. */
|
||||
export interface SocketSend {
|
||||
send(json: string): void;
|
||||
}
|
||||
|
||||
/** Array-backed turn event sink (push + drain). */
|
||||
class ArraySink implements TurnEventSink {
|
||||
constructor(public events: TurnEvent[] = []) {}
|
||||
push(event: TurnEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
drain(): TurnEvent[] {
|
||||
const out = this.events;
|
||||
this.events = [];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the token against ZESDEX_WS_TOKEN (when configured). */
|
||||
function tokenAllowed(url: URL): boolean {
|
||||
const configured = process.env.ZESDEX_WS_TOKEN;
|
||||
const t = url.searchParams.get("token");
|
||||
if (configured) {
|
||||
return t === configured;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Wire a turn service for one prompt (matches the CLI composition root). */
|
||||
async function buildSingleShot(
|
||||
sessionDir: string,
|
||||
eventSink: TurnEventSink,
|
||||
): Promise<{ turnService: AgentTurnServiceImpl; params: AgentTurnParams }> {
|
||||
const store = newStore();
|
||||
await ensureStoreDirs(store);
|
||||
|
||||
const settingsRepo = new JsonSettingsRepository();
|
||||
const appConfigRepo = new JsonAppConfigRepository();
|
||||
const settings = await settingsRepo.load(store.base_dir);
|
||||
const appConfig = await appConfigRepo.load(store.base_dir);
|
||||
|
||||
const provider = settings.provider;
|
||||
const model = resolveEffectiveModel(settings, appConfig);
|
||||
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
|
||||
const apiKey = resolveApiKey(settings, appConfig);
|
||||
|
||||
const llmClient = new LlmClient(apiKey, model, baseUrl);
|
||||
const toolCtx = {
|
||||
sessionDir,
|
||||
workspaces: [sessionDir],
|
||||
turnEvents: eventSink,
|
||||
workflowFindings: [],
|
||||
} as unknown as ToolCtx;
|
||||
const executor = new InfrastructureToolExecutor(toolCtx);
|
||||
const turnService = new AgentTurnServiceImpl(llmClient, executor, toolDefs(allTools()) as never);
|
||||
|
||||
const params: AgentTurnParams = {
|
||||
messages: [userMessage("")],
|
||||
session_dir: sessionDir,
|
||||
workspace_roots: [sessionDir],
|
||||
turn_events: eventSink,
|
||||
in_flight: { value: false },
|
||||
abort: new AbortController(),
|
||||
api_key: apiKey,
|
||||
model,
|
||||
api_base: baseUrl,
|
||||
};
|
||||
|
||||
return { turnService, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the WS server. `onPrompt` is invoked with the client's prompt payload,
|
||||
* and `forward` sends raw strings to the client.
|
||||
*/
|
||||
export function startWsServer(port: number): { stop: () => void } {
|
||||
const state: WsState = { store_base_dir: ".", session_id: null };
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/ws") {
|
||||
if (!tokenAllowed(url)) {
|
||||
return new Response("missing or invalid token", { status: 401 });
|
||||
}
|
||||
if (server.upgrade(req, {})) {
|
||||
return undefined;
|
||||
}
|
||||
return new Response("upgrade failed", { status: 400 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const sender: SocketSend = { send: (s) => ws.send(s) };
|
||||
// Welcome message on connect.
|
||||
sender.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
session: state.session_id,
|
||||
message: "Connected to Zesdex WebSocket server",
|
||||
}),
|
||||
);
|
||||
},
|
||||
message(ws, msg) {
|
||||
const text = typeof msg === "string" ? msg : JSON.stringify(msg);
|
||||
if (!text.startsWith("{")) {
|
||||
ws.send(JSON.stringify({ type: "echo", data: text }));
|
||||
return;
|
||||
}
|
||||
let val: Record<string, unknown>;
|
||||
try {
|
||||
val = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "echo", data: text }));
|
||||
return;
|
||||
}
|
||||
if (val.type === "prompt" && typeof val.message === "string") {
|
||||
const sender: SocketSend = { send: (s) => ws.send(s) };
|
||||
void runPrompt(sender, val.message, typeof val.model === "string" ? val.model : undefined);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: "echo", data: text }));
|
||||
}
|
||||
},
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
console.log(`zesdex-ws listening on ws://0.0.0.0:${server.port}`);
|
||||
return { stop: () => server.stop(true) };
|
||||
}
|
||||
|
||||
/** Run a single prompt and stream events to the client. */
|
||||
async function runPrompt(ws: SocketSend, message: string, modelOverride?: string): Promise<void> {
|
||||
const sessionDir = process.cwd();
|
||||
const sink = new ArraySink();
|
||||
try {
|
||||
const { turnService, params } = await buildSingleShot(sessionDir, sink);
|
||||
if (modelOverride) params.model = modelOverride;
|
||||
params.messages = [userMessage(message)];
|
||||
|
||||
// Poll the sink and forward events to the client.
|
||||
const poll = setInterval(() => {
|
||||
const evs = sink.drain();
|
||||
for (const ev of evs) {
|
||||
if (ev.kind === "stream_token") {
|
||||
ws.send(JSON.stringify({ type: "token", content: ev.content }));
|
||||
} else if (ev.kind === "done") {
|
||||
ws.send(JSON.stringify({ type: "done" }));
|
||||
clearInterval(poll);
|
||||
} else if (ev.kind === "error") {
|
||||
ws.send(JSON.stringify({ type: "error", message: ev.message }));
|
||||
clearInterval(poll);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
await turnService.runTurn(params);
|
||||
// Ensure a terminal done/error is sent even if none auto-emitted
|
||||
// while the poll stopped early.
|
||||
const remaining = sink.drain();
|
||||
for (const ev of remaining) {
|
||||
if (ev.kind === "stream_token") ws.send(JSON.stringify({ type: "token", content: ev.content }));
|
||||
else if (ev.kind === "done") ws.send(JSON.stringify({ type: "done" }));
|
||||
else if (ev.kind === "error") ws.send(JSON.stringify({ type: "error", message: ev.message }));
|
||||
}
|
||||
if (!remaining.some((e) => e.kind === "done" || e.kind === "error")) {
|
||||
ws.send(JSON.stringify({ type: "done" }));
|
||||
}
|
||||
} finally {
|
||||
clearInterval(poll);
|
||||
}
|
||||
} catch (e) {
|
||||
ws.send(JSON.stringify({ type: "error", message: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user