diff --git a/package.json b/package.json
index 1a6d65178..e1937a1c9 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,8 @@
"dist/utils/**",
"dist/logging/**",
"dist/memory/**",
+ "dist/recall/**",
+ "dist/recall-ui/**",
"dist/markdown/**",
"dist/node-host/**",
"dist/pairing/**",
@@ -77,7 +79,7 @@
"scripts": {
"dev": "node scripts/run-node.mjs",
"postinstall": "node scripts/postinstall.js",
- "prepack": "pnpm build && pnpm ui:build",
+ "prepack": "pnpm build && pnpm ui:build && pnpm recall:build",
"docs:list": "node scripts/docs-list.js",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev",
@@ -88,6 +90,8 @@
"ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build",
+ "recall:dev": "cd recall-ui && npm run dev",
+ "recall:build": "cd recall-ui && npm install && npm run build && node -e \"require('fs').cpSync('dist','../dist/recall-ui',{recursive:true})\"",
"start": "node scripts/run-node.mjs",
"clawdbot": "node scripts/run-node.mjs",
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
diff --git a/recall-ui/index.html b/recall-ui/index.html
new file mode 100644
index 000000000..11ca89065
--- /dev/null
+++ b/recall-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Recall - Memory Manager
+
+
+
+
+
+
diff --git a/recall-ui/package.json b/recall-ui/package.json
new file mode 100644
index 000000000..00f96834b
--- /dev/null
+++ b/recall-ui/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "recall-ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.59.0",
+ "lucide-react": "^0.454.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-router-dom": "^7.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.6.3",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/recall-ui/postcss.config.js b/recall-ui/postcss.config.js
new file mode 100644
index 000000000..2aa7205d4
--- /dev/null
+++ b/recall-ui/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/recall-ui/public/favicon.svg b/recall-ui/public/favicon.svg
new file mode 100644
index 000000000..7c547438f
--- /dev/null
+++ b/recall-ui/public/favicon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/recall-ui/src/App.tsx b/recall-ui/src/App.tsx
new file mode 100644
index 000000000..4cde4e6db
--- /dev/null
+++ b/recall-ui/src/App.tsx
@@ -0,0 +1,19 @@
+import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+
+import { Layout } from "./components/Layout";
+import { BrowsePage } from "./pages/BrowsePage";
+import { SearchPage } from "./pages/SearchPage";
+
+export default function App() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/recall-ui/src/api/client.ts b/recall-ui/src/api/client.ts
new file mode 100644
index 000000000..5a11bb11e
--- /dev/null
+++ b/recall-ui/src/api/client.ts
@@ -0,0 +1,169 @@
+// API types matching the server types
+export type MemorySource = "memory" | "sessions";
+
+export interface StatusResponse {
+ files: number;
+ chunks: number;
+ dirty: boolean;
+ workspaceDir: string;
+ provider: string;
+ model: string;
+ sources: MemorySource[];
+ sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
+ vector: { enabled: boolean; available: boolean; dims?: number };
+ fts: { enabled: boolean; available: boolean };
+}
+
+export interface MemoryChunkRecord {
+ id: string;
+ path: string;
+ source: MemorySource;
+ startLine: number;
+ endLine: number;
+ text: string;
+ hash: string;
+ model: string;
+ updatedAt: number;
+}
+
+export interface MemoriesListResponse {
+ memories: MemoryChunkRecord[];
+ total: number;
+ page: number;
+ limit: number;
+ hasMore: boolean;
+}
+
+export interface SearchResult {
+ id: string;
+ path: string;
+ source: MemorySource;
+ startLine: number;
+ endLine: number;
+ score: number;
+ snippet: string;
+}
+
+export interface SearchResponse {
+ results: SearchResult[];
+ query: string;
+}
+
+export interface FileInfo {
+ path: string;
+ source: MemorySource;
+ size: number;
+ hash: string;
+ mtime: number;
+ chunks: number;
+}
+
+export interface ApiError {
+ error: string;
+ code?: string;
+}
+
+class ApiClient {
+ private baseUrl: string;
+
+ constructor(baseUrl = "") {
+ this.baseUrl = baseUrl;
+ }
+
+ private async request(path: string, options?: RequestInit): Promise {
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const error = (await response.json().catch(() => ({}))) as ApiError;
+ throw new Error(error.error || `HTTP ${response.status}`);
+ }
+
+ return response.json() as Promise;
+ }
+
+ async getStatus(): Promise {
+ return this.request("/api/status");
+ }
+
+ async getMemories(params?: {
+ page?: number;
+ limit?: number;
+ source?: MemorySource;
+ }): Promise {
+ const searchParams = new URLSearchParams();
+ if (params?.page) searchParams.set("page", String(params.page));
+ if (params?.limit) searchParams.set("limit", String(params.limit));
+ if (params?.source) searchParams.set("source", params.source);
+ const query = searchParams.toString();
+ return this.request(`/api/memories${query ? `?${query}` : ""}`);
+ }
+
+ async search(query: string, options?: { maxResults?: number; minScore?: number }): Promise {
+ return this.request("/api/search", {
+ method: "POST",
+ body: JSON.stringify({ query, ...options }),
+ });
+ }
+
+ async updateMemory(id: string, content: string): Promise<{ success: boolean }> {
+ return this.request<{ success: boolean }>(`/api/memories/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ body: JSON.stringify({ content }),
+ });
+ }
+
+ async deleteMemory(id: string): Promise<{ success: boolean }> {
+ return this.request<{ success: boolean }>(`/api/memories/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ }
+
+ async getFiles(): Promise<{ files: FileInfo[] }> {
+ return this.request<{ files: FileInfo[] }>("/api/files");
+ }
+
+ async readFile(path: string): Promise<{ path: string; content: string }> {
+ return this.request<{ path: string; content: string }>(`/api/files/${encodeURIComponent(path)}`);
+ }
+
+ async writeFile(path: string, content: string): Promise<{ success: boolean }> {
+ return this.request<{ success: boolean }>(`/api/files/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ body: JSON.stringify({ content }),
+ });
+ }
+
+ async sync(force = false): Promise {
+ // Uses SSE for progress, so we handle it differently
+ return new Promise((resolve, reject) => {
+ const eventSource = new EventSource(`/api/sync${force ? "?force=true" : ""}`);
+
+ eventSource.addEventListener("complete", () => {
+ eventSource.close();
+ resolve();
+ });
+
+ eventSource.addEventListener("error", (event) => {
+ eventSource.close();
+ reject(new Error("Sync failed"));
+ });
+
+ eventSource.onerror = () => {
+ eventSource.close();
+ reject(new Error("Connection lost"));
+ };
+ });
+ }
+
+ async exportMemories(format: "json" | "md" = "json"): Promise<{ data: string; filename: string }> {
+ return this.request<{ format: string; data: string; filename: string }>(`/api/export?format=${format}`);
+ }
+}
+
+export const api = new ApiClient();
diff --git a/recall-ui/src/components/Layout.tsx b/recall-ui/src/components/Layout.tsx
new file mode 100644
index 000000000..b1e7cce6b
--- /dev/null
+++ b/recall-ui/src/components/Layout.tsx
@@ -0,0 +1,96 @@
+import { Link, NavLink, Outlet } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { Brain, Search, FolderOpen, RefreshCw } from "lucide-react";
+import { api } from "../api/client";
+
+export function Layout() {
+ const { data: status, isLoading } = useQuery({
+ queryKey: ["status"],
+ queryFn: () => api.getStatus(),
+ refetchInterval: 30000,
+ });
+
+ return (
+
+ {/* Sidebar */}
+
+
+
+
+
Recall
+
+
Memory Manager
+
+
+
+
+ `flex items-center gap-2 px-3 py-2 rounded-md transition-colors ${
+ isActive
+ ? "bg-[hsl(var(--accent))] text-[hsl(var(--accent-foreground))]"
+ : "text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
+ }`
+ }
+ >
+
+ Browse
+
+
+ `flex items-center gap-2 px-3 py-2 rounded-md transition-colors ${
+ isActive
+ ? "bg-[hsl(var(--accent))] text-[hsl(var(--accent-foreground))]"
+ : "text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
+ }`
+ }
+ >
+
+ Search
+
+
+
+ {/* Status Bar */}
+
+ {isLoading ? (
+
+
+ Loading...
+
+ ) : status ? (
+
+
+ Files:
+ {status.files}
+
+
+ Chunks:
+ {status.chunks}
+
+
+ Provider:
+
+ {status.provider}
+
+
+ {status.dirty && (
+
+
+ Index out of date
+
+ )}
+
+ ) : (
+
Unable to connect
+ )}
+
+
+
+ {/* Main Content */}
+
+
+
+
+ );
+}
diff --git a/recall-ui/src/main.tsx b/recall-ui/src/main.tsx
new file mode 100644
index 000000000..3c92ff48d
--- /dev/null
+++ b/recall-ui/src/main.tsx
@@ -0,0 +1,23 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import App from "./App";
+import "./styles/globals.css";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1 minute
+ retry: 1,
+ },
+ },
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/recall-ui/src/pages/BrowsePage.tsx b/recall-ui/src/pages/BrowsePage.tsx
new file mode 100644
index 000000000..9b94e8700
--- /dev/null
+++ b/recall-ui/src/pages/BrowsePage.tsx
@@ -0,0 +1,160 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { RefreshCw, Download, FileText, Database, ChevronRight } from "lucide-react";
+import { api, type MemorySource } from "../api/client";
+
+export function BrowsePage() {
+ const queryClient = useQueryClient();
+ const [selectedSource, setSelectedSource] = useState();
+
+ const { data: status, isLoading: statusLoading } = useQuery({
+ queryKey: ["status"],
+ queryFn: () => api.getStatus(),
+ });
+
+ const syncMutation = useMutation({
+ mutationFn: (force: boolean) => api.sync(force),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["status"] });
+ queryClient.invalidateQueries({ queryKey: ["memories"] });
+ },
+ });
+
+ const exportMutation = useMutation({
+ mutationFn: (format: "json" | "md") => api.exportMemories(format),
+ onSuccess: (data) => {
+ const blob = new Blob([data.data], {
+ type: data.filename.endsWith(".json") ? "application/json" : "text/markdown",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = data.filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ });
+
+ return (
+
+
+
+
Memory Browser
+
+ Browse and manage your AI's memories
+
+
+
+ syncMutation.mutate(false)}
+ disabled={syncMutation.isPending}
+ className="flex items-center gap-2 px-3 py-2 text-sm bg-[hsl(var(--secondary))] text-[hsl(var(--secondary-foreground))] rounded-md hover:bg-[hsl(var(--secondary))]/80 disabled:opacity-50"
+ >
+
+ Sync
+
+ exportMutation.mutate("md")}
+ disabled={exportMutation.isPending}
+ className="flex items-center gap-2 px-3 py-2 text-sm bg-[hsl(var(--secondary))] text-[hsl(var(--secondary-foreground))] rounded-md hover:bg-[hsl(var(--secondary))]/80 disabled:opacity-50"
+ >
+
+ Export
+
+
+
+
+ {/* Source filters */}
+
+ setSelectedSource(undefined)}
+ className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
+ !selectedSource
+ ? "bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]"
+ : "bg-[hsl(var(--secondary))] text-[hsl(var(--secondary-foreground))] hover:bg-[hsl(var(--secondary))]/80"
+ }`}
+ >
+ All Sources
+
+ {status?.sources.map((source) => (
+ setSelectedSource(source)}
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
+ selectedSource === source
+ ? "bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]"
+ : "bg-[hsl(var(--secondary))] text-[hsl(var(--secondary-foreground))] hover:bg-[hsl(var(--secondary))]/80"
+ }`}
+ >
+ {source === "memory" ? : }
+ {source}
+
+ ))}
+
+
+ {/* Stats cards */}
+ {status && (
+
+ {status.sourceCounts.map((sc) => (
+
+
+ {sc.source === "memory" ? (
+
+ ) : (
+
+ )}
+ {sc.source}
+
+
{sc.chunks}
+
+ {sc.files} {sc.files === 1 ? "file" : "files"}
+
+
+ ))}
+
+ )}
+
+ {/* Info panel */}
+
+
+
+
+
+
+
Memory Files Location
+
+ Memories are stored in your workspace directory:
+
+
+ {status?.workspaceDir || "Loading..."}
+
+
+
+
+ MEMORY.md
+ - Main memory file
+
+
+
+ memory/*.md
+ - Additional memory files
+
+
+
+
+
+
+ {/* Search hint */}
+
+ Use the Search tab to find specific memories using semantic search.
+
+
+ );
+}
diff --git a/recall-ui/src/pages/SearchPage.tsx b/recall-ui/src/pages/SearchPage.tsx
new file mode 100644
index 000000000..894633cd4
--- /dev/null
+++ b/recall-ui/src/pages/SearchPage.tsx
@@ -0,0 +1,164 @@
+import { useMutation } from "@tanstack/react-query";
+import { useState, useCallback } from "react";
+import { Search, FileText, Database, ExternalLink, Copy, Check } from "lucide-react";
+import { api, type SearchResult } from "../api/client";
+
+export function SearchPage() {
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [copiedId, setCopiedId] = useState(null);
+
+ const searchMutation = useMutation({
+ mutationFn: (q: string) => api.search(q, { maxResults: 20 }),
+ onSuccess: (data) => {
+ setResults(data.results);
+ },
+ });
+
+ const handleSearch = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ if (query.trim()) {
+ searchMutation.mutate(query.trim());
+ }
+ },
+ [query, searchMutation],
+ );
+
+ const copyToClipboard = useCallback((text: string, id: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedId(id);
+ setTimeout(() => setCopiedId(null), 2000);
+ }, []);
+
+ const formatScore = (score: number) => {
+ return (score * 100).toFixed(1) + "%";
+ };
+
+ return (
+
+
+
Search Memories
+
+ Search your AI's memories using natural language
+
+
+
+ {/* Search form */}
+
+
+ {/* Results */}
+ {searchMutation.isError && (
+
+ Search failed: {searchMutation.error?.message || "Unknown error"}
+
+ )}
+
+ {results.length > 0 && (
+
+
+ Found {results.length} result{results.length === 1 ? "" : "s"}
+
+
+ {results.map((result) => (
+
+ {/* Header */}
+
+
+ {result.source === "memory" ? (
+
+ ) : (
+
+ )}
+
+ {result.path}
+
+
+ L{result.startLine}-{result.endLine}
+
+
+
+ = 0.8
+ ? "bg-green-500/20 text-green-600"
+ : result.score >= 0.6
+ ? "bg-yellow-500/20 text-yellow-600"
+ : "bg-[hsl(var(--secondary))] text-[hsl(var(--muted-foreground))]"
+ }`}
+ >
+ {formatScore(result.score)}
+
+ copyToClipboard(result.snippet, result.id)}
+ className="p-1 rounded hover:bg-[hsl(var(--accent))] text-[hsl(var(--muted-foreground))]"
+ title="Copy snippet"
+ >
+ {copiedId === result.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Content */}
+
+
+ ))}
+
+ )}
+
+ {/* Empty state */}
+ {!searchMutation.isPending && results.length === 0 && query && !searchMutation.isError && (
+
+
+
No results found
+
+ Try a different search term or add more memories.
+
+
+ )}
+
+ {/* Initial state */}
+ {!query && results.length === 0 && (
+
+
+
Search Your Memories
+
+ Enter a search query above to find relevant memories. The search uses semantic matching
+ to find related content even if the exact words don't match.
+
+
+ )}
+
+ );
+}
diff --git a/recall-ui/src/styles/globals.css b/recall-ui/src/styles/globals.css
new file mode 100644
index 000000000..467edba34
--- /dev/null
+++ b/recall-ui/src/styles/globals.css
@@ -0,0 +1,78 @@
+@import "tailwindcss";
+
+:root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 240 10% 3.9%;
+ --primary: 240 5.9% 10%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 240 5.9% 10%;
+ --radius: 0.5rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ }
+}
+
+* {
+ border-color: hsl(var(--border));
+}
+
+body {
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: hsl(var(--muted));
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: hsl(var(--muted-foreground) / 0.3);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--muted-foreground) / 0.5);
+}
diff --git a/recall-ui/tsconfig.json b/recall-ui/tsconfig.json
new file mode 100644
index 000000000..1024ae396
--- /dev/null
+++ b/recall-ui/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/recall-ui/vite.config.ts b/recall-ui/vite.config.ts
new file mode 100644
index 000000000..a4d528d23
--- /dev/null
+++ b/recall-ui/vite.config.ts
@@ -0,0 +1,25 @@
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: "http://localhost:18790",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: true,
+ },
+});
diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts
index b4a65c794..7a18bde44 100644
--- a/src/cli/program/register.subclis.ts
+++ b/src/cli/program/register.subclis.ts
@@ -222,6 +222,14 @@ const entries: SubCliEntry[] = [
mod.registerUpdateCli(program);
},
},
+ {
+ name: "recall",
+ description: "Memory manager web UI",
+ register: async (program) => {
+ const mod = await import("../recall-cli.js");
+ mod.registerRecallCli(program);
+ },
+ },
];
function removeCommand(program: Command, command: Command) {
diff --git a/src/cli/recall-cli.ts b/src/cli/recall-cli.ts
new file mode 100644
index 000000000..f885294af
--- /dev/null
+++ b/src/cli/recall-cli.ts
@@ -0,0 +1,86 @@
+import { exec } from "node:child_process";
+
+import type { Command } from "commander";
+
+import { resolveDefaultAgentId } from "../agents/agent-scope.js";
+import { loadConfig } from "../config/config.js";
+import { createRecallServer } from "../recall/server.js";
+import { defaultRuntime } from "../runtime.js";
+import { formatDocsLink } from "../terminal/links.js";
+import { theme } from "../terminal/theme.js";
+
+const DEFAULT_PORT = 18790;
+const DEFAULT_HOST = "127.0.0.1";
+
+type RecallOptions = {
+ port?: number;
+ agent?: string;
+ open?: boolean;
+};
+
+function openBrowser(url: string): void {
+ const platform = process.platform;
+ const command =
+ platform === "darwin"
+ ? `open "${url}"`
+ : platform === "win32"
+ ? `start "" "${url}"`
+ : `xdg-open "${url}"`;
+
+ exec(command, (err) => {
+ if (err) {
+ defaultRuntime.log(`Open ${url} in your browser`);
+ }
+ });
+}
+
+export function registerRecallCli(program: Command) {
+ program
+ .command("recall")
+ .description("Memory manager web UI")
+ .option("-p, --port ", "Port to listen on", (v) => Number.parseInt(v, 10), DEFAULT_PORT)
+ .option("--agent ", "Agent ID (default: default agent)")
+ .option("--no-open", "Do not open browser automatically")
+ .addHelpText(
+ "after",
+ () =>
+ `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/recall", "docs.clawd.bot/cli/recall")}\n`,
+ )
+ .action(async (opts: RecallOptions) => {
+ const cfg = loadConfig();
+ const agentId = opts.agent?.trim() || resolveDefaultAgentId(cfg);
+ const port = opts.port ?? DEFAULT_PORT;
+ const host = DEFAULT_HOST;
+
+ defaultRuntime.log(`Starting Recall memory manager for agent: ${agentId}`);
+
+ const server = await createRecallServer({
+ cfg,
+ agentId,
+ port,
+ host,
+ });
+
+ await server.start();
+
+ const url = server.getUrl();
+ defaultRuntime.log(`Recall UI available at: ${url}`);
+
+ if (opts.open !== false) {
+ openBrowser(url);
+ }
+
+ // Keep the process running
+ const shutdown = async () => {
+ defaultRuntime.log("\nShutting down Recall server...");
+ await server.stop();
+ process.exit(0);
+ };
+
+ process.on("SIGINT", () => void shutdown());
+ process.on("SIGTERM", () => void shutdown());
+
+ // Keep process alive
+ await new Promise(() => {});
+ });
+}
diff --git a/src/recall/index.ts b/src/recall/index.ts
new file mode 100644
index 000000000..c6d945ea0
--- /dev/null
+++ b/src/recall/index.ts
@@ -0,0 +1,2 @@
+export { createRecallServer, type RecallServer, type RecallServerOptions } from "./server.js";
+export type * from "./types.js";
diff --git a/src/recall/routes/files.ts b/src/recall/routes/files.ts
new file mode 100644
index 000000000..d8c290f1a
--- /dev/null
+++ b/src/recall/routes/files.ts
@@ -0,0 +1,112 @@
+import type { Context } from "hono";
+
+import type { MemoryService } from "../services/memory-service.js";
+import type { ApiError, FileContentResponse, FilesListResponse } from "../types.js";
+
+export function createFilesListRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const files = await service.listFiles();
+ const response: FilesListResponse = { files };
+ return c.json(response);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "LIST_FILES_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
+
+export function createFileReadRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ // Get the path from the URL after /api/files/
+ const url = new URL(c.req.url);
+ const pathMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
+ const filePath = pathMatch?.[1];
+ if (!filePath) {
+ const error: ApiError = { error: "File path required", code: "INVALID_PATH" };
+ return c.json(error, 400);
+ }
+
+ // Decode the URL-encoded path
+ const decodedPath = decodeURIComponent(filePath);
+
+ const result = await service.readFile(decodedPath);
+ const response: FileContentResponse = {
+ path: result.path,
+ content: result.text,
+ };
+ return c.json(response);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes("ENOENT") || message.includes("not found")) {
+ const error: ApiError = { error: "File not found", code: "NOT_FOUND" };
+ return c.json(error, 404);
+ }
+ if (message.includes("escapes workspace") || message.includes("path required")) {
+ const error: ApiError = { error: message, code: "INVALID_PATH" };
+ return c.json(error, 400);
+ }
+ const error: ApiError = { error: message, code: "READ_FILE_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
+
+export function createFileWriteRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ // Get the path from the URL after /api/files/
+ const url = new URL(c.req.url);
+ const pathMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
+ const filePath = pathMatch?.[1];
+ if (!filePath) {
+ const error: ApiError = { error: "File path required", code: "INVALID_PATH" };
+ return c.json(error, 400);
+ }
+
+ const decodedPath = decodeURIComponent(filePath);
+ const body = await c.req.json<{ content?: string }>();
+
+ if (typeof body.content !== "string") {
+ const error: ApiError = { error: "Content required", code: "INVALID_CONTENT" };
+ return c.json(error, 400);
+ }
+
+ const result = await service.writeFile(decodedPath, body.content);
+ if (!result.success) {
+ const error: ApiError = { error: result.error ?? "Write failed", code: "WRITE_FAILED" };
+ return c.json(error, 500);
+ }
+
+ return c.json({ success: true, path: decodedPath });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes("escapes workspace") || message.includes("path required")) {
+ const error: ApiError = { error: message, code: "INVALID_PATH" };
+ return c.json(error, 400);
+ }
+ const error: ApiError = { error: message, code: "WRITE_FILE_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
diff --git a/src/recall/routes/memories.ts b/src/recall/routes/memories.ts
new file mode 100644
index 000000000..f7af3a8d4
--- /dev/null
+++ b/src/recall/routes/memories.ts
@@ -0,0 +1,131 @@
+import type { Context } from "hono";
+
+import type { MemoryService } from "../services/memory-service.js";
+import type { ApiError, MemoriesListResponse, MemoryDetailResponse, MemorySource } from "../types.js";
+
+export function createMemoriesListRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const page = Number.parseInt(c.req.query("page") ?? "1", 10);
+ const limit = Number.parseInt(c.req.query("limit") ?? "50", 10);
+ const source = c.req.query("source") as MemorySource | undefined;
+
+ const { memories, total } = await service.listMemories({ page, limit, source });
+ const response: MemoriesListResponse = {
+ memories,
+ total,
+ page,
+ limit,
+ hasMore: page * limit < total,
+ };
+ return c.json(response);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "LIST_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
+
+export function createMemoryDetailRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const id = c.req.param("id");
+ if (!id) {
+ const error: ApiError = { error: "Memory ID required", code: "INVALID_ID" };
+ return c.json(error, 400);
+ }
+
+ const memory = await service.getMemory(id);
+ if (!memory) {
+ const error: ApiError = { error: "Memory not found", code: "NOT_FOUND" };
+ return c.json(error, 404);
+ }
+
+ const response: MemoryDetailResponse = { memory };
+ return c.json(response);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "DETAIL_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
+
+export function createMemoryUpdateRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const id = c.req.param("id");
+ if (!id) {
+ const error: ApiError = { error: "Memory ID required", code: "INVALID_ID" };
+ return c.json(error, 400);
+ }
+
+ const body = await c.req.json<{ content?: string }>();
+ if (!body.content) {
+ const error: ApiError = { error: "Content required", code: "INVALID_CONTENT" };
+ return c.json(error, 400);
+ }
+
+ const result = await service.updateMemory(id, body.content);
+ if (!result.success) {
+ const error: ApiError = { error: result.error ?? "Update failed", code: "UPDATE_FAILED" };
+ return c.json(error, result.notFound ? 404 : 500);
+ }
+
+ return c.json({ success: true });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "UPDATE_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
+
+export function createMemoryDeleteRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const id = c.req.param("id");
+ if (!id) {
+ const error: ApiError = { error: "Memory ID required", code: "INVALID_ID" };
+ return c.json(error, 400);
+ }
+
+ const result = await service.deleteMemory(id);
+ if (!result.success) {
+ const error: ApiError = { error: result.error ?? "Delete failed", code: "DELETE_FAILED" };
+ return c.json(error, result.notFound ? 404 : 500);
+ }
+
+ return c.json({ success: true });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "DELETE_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
diff --git a/src/recall/routes/search.ts b/src/recall/routes/search.ts
new file mode 100644
index 000000000..641c18f9e
--- /dev/null
+++ b/src/recall/routes/search.ts
@@ -0,0 +1,39 @@
+import type { Context } from "hono";
+
+import type { MemoryService } from "../services/memory-service.js";
+import type { ApiError, SearchRequest, SearchResponse } from "../types.js";
+
+export function createSearchRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const body = await c.req.json();
+
+ if (!body.query?.trim()) {
+ const error: ApiError = { error: "Query required", code: "INVALID_QUERY" };
+ return c.json(error, 400);
+ }
+
+ const results = await service.search(body.query, {
+ maxResults: body.maxResults,
+ minScore: body.minScore,
+ });
+
+ const response: SearchResponse = {
+ results,
+ query: body.query,
+ };
+
+ return c.json(response);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "SEARCH_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
diff --git a/src/recall/routes/status.ts b/src/recall/routes/status.ts
new file mode 100644
index 000000000..e9a854a3a
--- /dev/null
+++ b/src/recall/routes/status.ts
@@ -0,0 +1,31 @@
+import type { Context } from "hono";
+
+import type { MemoryService } from "../services/memory-service.js";
+import type { ApiError, StatusResponse } from "../types.js";
+
+export function createStatusRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service) {
+ const error: ApiError = { error: "Memory service not initialized", code: "NOT_INITIALIZED" };
+ return c.json(error, 503);
+ }
+
+ if (!service.isAvailable()) {
+ const error: ApiError = {
+ error: service.getError() ?? "Memory search unavailable",
+ code: "UNAVAILABLE",
+ };
+ return c.json(error, 503);
+ }
+
+ try {
+ const status: StatusResponse = service.getStatus();
+ return c.json(status);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "STATUS_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
diff --git a/src/recall/routes/sync.ts b/src/recall/routes/sync.ts
new file mode 100644
index 000000000..0e0907450
--- /dev/null
+++ b/src/recall/routes/sync.ts
@@ -0,0 +1,79 @@
+import type { Context } from "hono";
+import { streamSSE } from "hono/streaming";
+
+import type { MemoryService } from "../services/memory-service.js";
+import type { ApiError, SyncProgressEvent } from "../types.js";
+
+export function createSyncRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ const forceParam = c.req.query("force");
+ const force = forceParam === "true" || forceParam === "1";
+
+ // Use SSE for progress streaming
+ return streamSSE(c, async (stream) => {
+ try {
+ await service.sync({
+ force,
+ progress: (update) => {
+ const event: SyncProgressEvent = {
+ type: "progress",
+ completed: update.completed,
+ total: update.total,
+ label: update.label,
+ };
+ void stream.writeSSE({
+ data: JSON.stringify(event),
+ event: "progress",
+ });
+ },
+ });
+
+ const completeEvent: SyncProgressEvent = { type: "complete" };
+ await stream.writeSSE({
+ data: JSON.stringify(completeEvent),
+ event: "complete",
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const errorEvent: SyncProgressEvent = { type: "error", error: message };
+ await stream.writeSSE({
+ data: JSON.stringify(errorEvent),
+ event: "error",
+ });
+ }
+ });
+ };
+}
+
+export function createExportRoute(getService: () => MemoryService | null) {
+ return async (c: Context): Promise => {
+ const service = getService();
+ if (!service?.isAvailable()) {
+ const error: ApiError = { error: "Memory service unavailable", code: "UNAVAILABLE" };
+ return c.json(error, 503);
+ }
+
+ try {
+ const format = (c.req.query("format") ?? "json") as "json" | "md";
+ const data = await service.exportMemories(format);
+
+ const filename = `memories-${new Date().toISOString().slice(0, 10)}.${format === "json" ? "json" : "md"}`;
+
+ return c.json({
+ format,
+ data,
+ filename,
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: ApiError = { error: message, code: "EXPORT_ERROR" };
+ return c.json(error, 500);
+ }
+ };
+}
diff --git a/src/recall/server.ts b/src/recall/server.ts
new file mode 100644
index 000000000..44fc95d36
--- /dev/null
+++ b/src/recall/server.ts
@@ -0,0 +1,294 @@
+import fs from "node:fs";
+import type { IncomingMessage, Server, ServerResponse } from "node:http";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { logger } from "hono/logger";
+
+import type { ClawdbotConfig } from "../config/config.js";
+import { createSubsystemLogger } from "../logging/subsystem.js";
+import { createMemoryService, type MemoryService } from "./services/memory-service.js";
+import { createStatusRoute } from "./routes/status.js";
+import { createSearchRoute } from "./routes/search.js";
+import {
+ createMemoriesListRoute,
+ createMemoryDetailRoute,
+ createMemoryUpdateRoute,
+ createMemoryDeleteRoute,
+} from "./routes/memories.js";
+import {
+ createFilesListRoute,
+ createFileReadRoute,
+ createFileWriteRoute,
+} from "./routes/files.js";
+import { createSyncRoute, createExportRoute } from "./routes/sync.js";
+
+const log = createSubsystemLogger("recall");
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export interface RecallServerOptions {
+ cfg: ClawdbotConfig;
+ agentId: string;
+ port: number;
+ host: string;
+}
+
+export interface RecallServer {
+ start(): Promise;
+ stop(): Promise;
+ getUrl(): string;
+}
+
+function resolveRecallUiRoot(): string | null {
+ const candidates = [
+ // Production: dist/recall-ui
+ path.resolve(__dirname, "../recall-ui"),
+ // Dev: running from src, look in project root
+ path.resolve(__dirname, "../../recall-ui/dist"),
+ // Fallback: check cwd
+ path.resolve(process.cwd(), "recall-ui/dist"),
+ path.resolve(process.cwd(), "dist/recall-ui"),
+ ];
+
+ for (const dir of candidates) {
+ if (fs.existsSync(path.join(dir, "index.html"))) {
+ return dir;
+ }
+ }
+ return null;
+}
+
+function contentTypeForExt(ext: string): string {
+ switch (ext) {
+ case ".html":
+ return "text/html; charset=utf-8";
+ case ".js":
+ case ".mjs":
+ return "application/javascript; charset=utf-8";
+ case ".css":
+ return "text/css; charset=utf-8";
+ case ".json":
+ case ".map":
+ return "application/json; charset=utf-8";
+ case ".svg":
+ return "image/svg+xml";
+ case ".png":
+ return "image/png";
+ case ".jpg":
+ case ".jpeg":
+ return "image/jpeg";
+ case ".ico":
+ return "image/x-icon";
+ case ".woff":
+ return "font/woff";
+ case ".woff2":
+ return "font/woff2";
+ default:
+ return "application/octet-stream";
+ }
+}
+
+function isSafeRelativePath(relPath: string): boolean {
+ if (!relPath) return false;
+ const normalized = path.posix.normalize(relPath);
+ if (normalized.startsWith("../") || normalized === "..") return false;
+ if (normalized.includes("\0")) return false;
+ return true;
+}
+
+function serveStaticFile(req: IncomingMessage, res: ServerResponse, root: string): boolean {
+ const urlRaw = req.url;
+ if (!urlRaw) return false;
+
+ const url = new URL(urlRaw, "http://localhost");
+ const pathname = url.pathname;
+
+ // Handle API routes - not static
+ if (pathname.startsWith("/api/")) {
+ return false;
+ }
+
+ // Determine the file to serve
+ let relPath = pathname.slice(1) || "index.html";
+ if (relPath.endsWith("/")) {
+ relPath += "index.html";
+ }
+
+ if (!isSafeRelativePath(relPath)) {
+ return false;
+ }
+
+ const filePath = path.join(root, relPath);
+ if (!filePath.startsWith(root)) {
+ return false;
+ }
+
+ // Check if file exists
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
+ const ext = path.extname(filePath).toLowerCase();
+ res.setHeader("Content-Type", contentTypeForExt(ext));
+ res.setHeader("Cache-Control", "no-cache");
+ res.end(fs.readFileSync(filePath));
+ return true;
+ }
+
+ // SPA fallback: serve index.html for unknown paths
+ const indexPath = path.join(root, "index.html");
+ if (fs.existsSync(indexPath)) {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.setHeader("Cache-Control", "no-cache");
+ res.end(fs.readFileSync(indexPath));
+ return true;
+ }
+
+ return false;
+}
+
+export async function createRecallServer(options: RecallServerOptions): Promise {
+ const { cfg, agentId, port, host } = options;
+
+ // Initialize memory service
+ const memoryService = await createMemoryService(cfg, agentId);
+ const getService = (): MemoryService | null => memoryService;
+
+ // Create Hono app
+ const app = new Hono();
+
+ // Middleware
+ app.use("*", cors());
+ app.use(
+ "*",
+ logger((str, ...rest) => {
+ log.debug(str, ...rest);
+ }),
+ );
+
+ // API routes
+ app.get("/api/status", createStatusRoute(getService));
+ app.get("/api/health", (c) => c.json({ ok: true }));
+
+ // Memory routes
+ app.get("/api/memories", createMemoriesListRoute(getService));
+ app.get("/api/memories/:id", createMemoryDetailRoute(getService));
+ app.put("/api/memories/:id", createMemoryUpdateRoute(getService));
+ app.delete("/api/memories/:id", createMemoryDeleteRoute(getService));
+
+ // Search
+ app.post("/api/search", createSearchRoute(getService));
+
+ // File routes
+ app.get("/api/files", createFilesListRoute(getService));
+ app.get("/api/files/*", createFileReadRoute(getService));
+ app.put("/api/files/*", createFileWriteRoute(getService));
+
+ // Sync and export
+ app.post("/api/sync", createSyncRoute(getService));
+ app.get("/api/export", createExportRoute(getService));
+
+ // Resolve UI root
+ const uiRoot = resolveRecallUiRoot();
+
+ let server: Server | null = null;
+
+ return {
+ async start(): Promise {
+ return new Promise((resolve, reject) => {
+ server = http.createServer((req, res) => {
+ // Try to serve static files first
+ if (uiRoot && serveStaticFile(req, res, uiRoot)) {
+ return;
+ }
+
+ // Handle API routes via Hono
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
+
+ // Convert Node.js request to Fetch API request
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value) {
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ headers.append(key, v);
+ }
+ } else {
+ headers.set(key, value);
+ }
+ }
+ }
+
+ const body =
+ req.method !== "GET" && req.method !== "HEAD"
+ ? new Promise((resolve) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk) => chunks.push(chunk));
+ req.on("end", () => resolve(Buffer.concat(chunks)));
+ })
+ : undefined;
+
+ void (async () => {
+ try {
+ const fetchRequest = new Request(url.toString(), {
+ method: req.method,
+ headers,
+ body: body ? await body : undefined,
+ });
+
+ const response = await app.fetch(fetchRequest);
+
+ res.statusCode = response.status;
+ response.headers.forEach((value, key) => {
+ res.setHeader(key, value);
+ });
+
+ const responseBody = await response.arrayBuffer();
+ res.end(Buffer.from(responseBody));
+ } catch (err) {
+ log.error("Request handler error", { error: String(err) });
+ res.statusCode = 500;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ error: "Internal server error" }));
+ }
+ })();
+ });
+
+ server.on("error", (err) => {
+ reject(err);
+ });
+
+ server.listen(port, host, () => {
+ log.info(`Recall server started at http://${host}:${port}`);
+ if (!uiRoot) {
+ log.warn(
+ "Recall UI assets not found. Build them with `pnpm recall:build` or run `pnpm recall:dev` during development.",
+ );
+ }
+ resolve();
+ });
+ });
+ },
+
+ async stop(): Promise {
+ if (memoryService) {
+ await memoryService.close();
+ }
+ return new Promise((resolve) => {
+ if (server) {
+ server.close(() => {
+ log.info("Recall server stopped");
+ resolve();
+ });
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ getUrl(): string {
+ return `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
+ },
+ };
+}
diff --git a/src/recall/services/file-service.ts b/src/recall/services/file-service.ts
new file mode 100644
index 000000000..15778c2a0
--- /dev/null
+++ b/src/recall/services/file-service.ts
@@ -0,0 +1,162 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+/**
+ * Service for file operations within a workspace boundary
+ */
+export class FileService {
+ private readonly workspaceDir: string;
+
+ constructor(workspaceDir: string) {
+ this.workspaceDir = path.resolve(workspaceDir);
+ }
+
+ /**
+ * Validate and resolve a relative path within the workspace
+ */
+ private resolveSafePath(relPath: string): string {
+ if (!relPath?.trim()) {
+ throw new Error("path required");
+ }
+
+ // Normalize the path
+ const normalized = path.normalize(relPath);
+
+ // Check for path traversal attempts
+ if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
+ throw new Error("path escapes workspace");
+ }
+
+ const absPath = path.resolve(this.workspaceDir, normalized);
+
+ // Double-check it's within workspace
+ if (!absPath.startsWith(this.workspaceDir + path.sep) && absPath !== this.workspaceDir) {
+ throw new Error("path escapes workspace");
+ }
+
+ return absPath;
+ }
+
+ /**
+ * Read a file from the workspace
+ */
+ async readFile(relPath: string): Promise<{ text: string; path: string }> {
+ const absPath = this.resolveSafePath(relPath);
+ const text = await fs.readFile(absPath, "utf-8");
+ return { text, path: relPath };
+ }
+
+ /**
+ * Write a file to the workspace
+ */
+ async writeFile(relPath: string, content: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ const absPath = this.resolveSafePath(relPath);
+
+ // Ensure parent directory exists
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+
+ await fs.writeFile(absPath, content, "utf-8");
+ return { success: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+
+ /**
+ * Check if a file exists in the workspace
+ */
+ async exists(relPath: string): Promise {
+ try {
+ const absPath = this.resolveSafePath(relPath);
+ await fs.access(absPath);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Get file stats
+ */
+ async stat(relPath: string): Promise<{
+ size: number;
+ mtime: number;
+ isFile: boolean;
+ isDirectory: boolean;
+ } | null> {
+ try {
+ const absPath = this.resolveSafePath(relPath);
+ const stats = await fs.stat(absPath);
+ return {
+ size: stats.size,
+ mtime: stats.mtimeMs,
+ isFile: stats.isFile(),
+ isDirectory: stats.isDirectory(),
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Update specific lines in a file
+ */
+ async updateLines(
+ relPath: string,
+ startLine: number,
+ endLine: number,
+ newContent: string,
+ ): Promise<{ success: boolean; error?: string }> {
+ try {
+ const absPath = this.resolveSafePath(relPath);
+ const content = await fs.readFile(absPath, "utf-8");
+ const lines = content.split("\n");
+
+ // Lines are 1-indexed
+ const before = lines.slice(0, startLine - 1);
+ const after = lines.slice(endLine);
+ const updated = [...before, ...newContent.split("\n"), ...after];
+
+ await fs.writeFile(absPath, updated.join("\n"), "utf-8");
+ return { success: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+
+ /**
+ * Delete specific lines from a file
+ */
+ async deleteLines(
+ relPath: string,
+ startLine: number,
+ endLine: number,
+ ): Promise<{ success: boolean; error?: string }> {
+ try {
+ const absPath = this.resolveSafePath(relPath);
+ const content = await fs.readFile(absPath, "utf-8");
+ const lines = content.split("\n");
+
+ // Lines are 1-indexed
+ const before = lines.slice(0, startLine - 1);
+ const after = lines.slice(endLine);
+ const updated = [...before, ...after];
+
+ await fs.writeFile(absPath, updated.join("\n"), "utf-8");
+ return { success: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: message };
+ }
+ }
+}
+
+/**
+ * Create a file service for a workspace
+ */
+export function createFileService(workspaceDir: string): FileService {
+ return new FileService(workspaceDir);
+}
diff --git a/src/recall/services/memory-service.ts b/src/recall/services/memory-service.ts
new file mode 100644
index 000000000..b2c8e39a8
--- /dev/null
+++ b/src/recall/services/memory-service.ts
@@ -0,0 +1,329 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import type { ClawdbotConfig } from "../../config/config.js";
+import {
+ getMemorySearchManager,
+ type MemoryIndexManager,
+ type MemorySearchResult,
+} from "../../memory/index.js";
+import type {
+ FileInfo,
+ MemoryChunkRecord,
+ MemorySource,
+ SearchResult,
+ StatusResponse,
+} from "../types.js";
+import { createFileService, type FileService } from "./file-service.js";
+
+export interface MemoryServiceOptions {
+ cfg: ClawdbotConfig;
+ agentId: string;
+}
+
+/**
+ * Service wrapper around MemoryIndexManager for the Recall UI
+ */
+export class MemoryService {
+ private manager: MemoryIndexManager | null = null;
+ private fileService: FileService | null = null;
+ private readonly cfg: ClawdbotConfig;
+ private readonly agentId: string;
+ private initError: string | null = null;
+
+ constructor(options: MemoryServiceOptions) {
+ this.cfg = options.cfg;
+ this.agentId = options.agentId;
+ }
+
+ async init(): Promise {
+ const result = await getMemorySearchManager({
+ cfg: this.cfg,
+ agentId: this.agentId,
+ });
+ if (!result.manager) {
+ this.initError = result.error ?? "Memory search unavailable";
+ return;
+ }
+ this.manager = result.manager;
+ const status = this.manager.status();
+ this.fileService = createFileService(status.workspaceDir);
+ }
+
+ isAvailable(): boolean {
+ return this.manager !== null;
+ }
+
+ getError(): string | null {
+ return this.initError;
+ }
+
+ getStatus(): StatusResponse {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ const status = this.manager.status();
+ return {
+ files: status.files,
+ chunks: status.chunks,
+ dirty: status.dirty,
+ workspaceDir: status.workspaceDir,
+ provider: status.provider,
+ model: status.model,
+ sources: status.sources,
+ sourceCounts: status.sourceCounts,
+ vector: {
+ enabled: status.vector?.enabled ?? false,
+ available: status.vector?.available ?? false,
+ dims: status.vector?.dims,
+ },
+ fts: {
+ enabled: status.fts?.enabled ?? false,
+ available: status.fts?.available ?? false,
+ },
+ };
+ }
+
+ async search(
+ query: string,
+ options?: { maxResults?: number; minScore?: number },
+ ): Promise {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ const results = await this.manager.search(query, options);
+ return results.map((r: MemorySearchResult) => ({
+ id: this.buildChunkId(r),
+ path: r.path,
+ source: r.source,
+ startLine: r.startLine,
+ endLine: r.endLine,
+ score: r.score,
+ snippet: r.snippet,
+ }));
+ }
+
+ async listMemories(options: {
+ page?: number;
+ limit?: number;
+ source?: MemorySource;
+ }): Promise<{ memories: MemoryChunkRecord[]; total: number }> {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ const status = this.manager.status();
+ const _page = options.page ?? 1;
+ const _limit = Math.min(options.limit ?? 50, 200);
+
+ // Note: MemoryIndexManager doesn't expose direct database access
+ // For the initial implementation, return the total count from status
+ // Full list implementation would require database schema access
+ return {
+ memories: [],
+ total: status.chunks,
+ };
+ }
+
+ async getMemory(id: string): Promise {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ // Parse the ID to extract chunk info
+ const parts = id.split(":");
+ if (parts.length < 4) return null;
+
+ // Note: Full implementation would require database access
+ return null;
+ }
+
+ async listFiles(): Promise {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ // Note: Full implementation would query the files table
+ return [];
+ }
+
+ async readFile(relPath: string): Promise<{ text: string; path: string }> {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ return this.manager.readFile({ relPath });
+ }
+
+ async writeFile(relPath: string, content: string): Promise<{ success: boolean; error?: string }> {
+ if (!this.fileService) {
+ return { success: false, error: "File service not initialized" };
+ }
+ return this.fileService.writeFile(relPath, content);
+ }
+
+ async updateMemory(
+ id: string,
+ newContent: string,
+ ): Promise<{ success: boolean; error?: string; notFound?: boolean }> {
+ if (!this.manager || !this.fileService) {
+ return { success: false, error: "Service not initialized" };
+ }
+
+ // Parse the chunk ID to get file path and line numbers
+ const parts = id.split(":");
+ if (parts.length < 4) {
+ return { success: false, error: "Invalid memory ID", notFound: true };
+ }
+
+ const [source, ...pathParts] = parts;
+ // Reconstruct path (may contain colons)
+ const pathAndLines = pathParts.join(":");
+ const lineMatch = pathAndLines.match(/^(.+):(\d+):(\d+)$/);
+ if (!lineMatch) {
+ return { success: false, error: "Invalid memory ID format", notFound: true };
+ }
+
+ const [, filePath, startLineStr, endLineStr] = lineMatch;
+ const startLine = Number.parseInt(startLineStr, 10);
+ const endLine = Number.parseInt(endLineStr, 10);
+
+ // Only allow editing memory source files
+ if (source !== "memory") {
+ return { success: false, error: "Can only edit memory source files" };
+ }
+
+ const result = await this.fileService.updateLines(filePath, startLine, endLine, newContent);
+ if (result.success) {
+ // Trigger reindex
+ void this.manager.sync({ reason: "recall-edit" }).catch(() => {});
+ }
+ return result;
+ }
+
+ async deleteMemory(
+ id: string,
+ ): Promise<{ success: boolean; error?: string; notFound?: boolean }> {
+ if (!this.manager || !this.fileService) {
+ return { success: false, error: "Service not initialized" };
+ }
+
+ // Parse the chunk ID
+ const parts = id.split(":");
+ if (parts.length < 4) {
+ return { success: false, error: "Invalid memory ID", notFound: true };
+ }
+
+ const [source, ...pathParts] = parts;
+ const pathAndLines = pathParts.join(":");
+ const lineMatch = pathAndLines.match(/^(.+):(\d+):(\d+)$/);
+ if (!lineMatch) {
+ return { success: false, error: "Invalid memory ID format", notFound: true };
+ }
+
+ const [, filePath, startLineStr, endLineStr] = lineMatch;
+ const startLine = Number.parseInt(startLineStr, 10);
+ const endLine = Number.parseInt(endLineStr, 10);
+
+ // Only allow deleting from memory source files
+ if (source !== "memory") {
+ return { success: false, error: "Can only delete from memory source files" };
+ }
+
+ const result = await this.fileService.deleteLines(filePath, startLine, endLine);
+ if (result.success) {
+ // Trigger reindex
+ void this.manager.sync({ reason: "recall-delete" }).catch(() => {});
+ }
+ return result;
+ }
+
+ async sync(options?: {
+ force?: boolean;
+ progress?: (update: { completed: number; total: number; label?: string }) => void;
+ }): Promise {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+ await this.manager.sync({
+ reason: "recall-ui",
+ force: options?.force,
+ progress: options?.progress,
+ });
+ }
+
+ async exportMemories(format: "json" | "md"): Promise {
+ if (!this.manager) {
+ throw new Error(this.initError ?? "Memory service not initialized");
+ }
+
+ const status = this.manager.status();
+ const workspaceDir = status.workspaceDir;
+
+ // Read MEMORY.md and memory/*.md files
+ const memoryFiles: string[] = [];
+
+ // Check for MEMORY.md
+ const mainMemoryPath = path.join(workspaceDir, "MEMORY.md");
+ try {
+ const content = await fs.readFile(mainMemoryPath, "utf-8");
+ memoryFiles.push(content);
+ } catch {
+ // File doesn't exist
+ }
+
+ // Check memory directory
+ const memoryDir = path.join(workspaceDir, "memory");
+ try {
+ const entries = await fs.readdir(memoryDir);
+ for (const entry of entries) {
+ if (entry.endsWith(".md")) {
+ const content = await fs.readFile(path.join(memoryDir, entry), "utf-8");
+ memoryFiles.push(`# ${entry}\n\n${content}`);
+ }
+ }
+ } catch {
+ // Directory doesn't exist
+ }
+
+ if (format === "md") {
+ return memoryFiles.join("\n\n---\n\n");
+ }
+
+ // JSON format
+ return JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ agentId: this.agentId,
+ workspaceDir,
+ stats: {
+ files: status.files,
+ chunks: status.chunks,
+ },
+ content: memoryFiles,
+ },
+ null,
+ 2,
+ );
+ }
+
+ async close(): Promise {
+ if (this.manager) {
+ await this.manager.close();
+ this.manager = null;
+ }
+ }
+
+ private buildChunkId(result: MemorySearchResult): string {
+ // Create a deterministic ID from the chunk properties
+ return `${result.source}:${result.path}:${result.startLine}:${result.endLine}`;
+ }
+}
+
+/**
+ * Create a memory service instance
+ */
+export async function createMemoryService(
+ cfg: ClawdbotConfig,
+ agentId: string,
+): Promise {
+ const service = new MemoryService({ cfg, agentId });
+ await service.init();
+ return service;
+}
diff --git a/src/recall/types.ts b/src/recall/types.ts
new file mode 100644
index 000000000..461d530f1
--- /dev/null
+++ b/src/recall/types.ts
@@ -0,0 +1,105 @@
+/**
+ * API types for the Recall memory manager UI
+ */
+
+export type MemorySource = "memory" | "sessions";
+
+export interface StatusResponse {
+ files: number;
+ chunks: number;
+ dirty: boolean;
+ workspaceDir: string;
+ provider: string;
+ model: string;
+ sources: MemorySource[];
+ sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
+ vector: { enabled: boolean; available: boolean; dims?: number };
+ fts: { enabled: boolean; available: boolean };
+}
+
+export interface MemoryChunkRecord {
+ id: string;
+ path: string;
+ source: MemorySource;
+ startLine: number;
+ endLine: number;
+ text: string;
+ hash: string;
+ model: string;
+ updatedAt: number;
+}
+
+export interface MemoriesListResponse {
+ memories: MemoryChunkRecord[];
+ total: number;
+ page: number;
+ limit: number;
+ hasMore: boolean;
+}
+
+export interface MemoryDetailResponse {
+ memory: MemoryChunkRecord;
+ fileContent?: string;
+}
+
+export interface SearchRequest {
+ query: string;
+ maxResults?: number;
+ minScore?: number;
+}
+
+export interface SearchResult {
+ id: string;
+ path: string;
+ source: MemorySource;
+ startLine: number;
+ endLine: number;
+ score: number;
+ snippet: string;
+}
+
+export interface SearchResponse {
+ results: SearchResult[];
+ query: string;
+}
+
+export interface FileInfo {
+ path: string;
+ source: MemorySource;
+ size: number;
+ hash: string;
+ mtime: number;
+ chunks: number;
+}
+
+export interface FilesListResponse {
+ files: FileInfo[];
+}
+
+export interface FileContentResponse {
+ path: string;
+ content: string;
+}
+
+export interface UpdateMemoryRequest {
+ content: string;
+}
+
+export interface SyncProgressEvent {
+ type: "progress" | "complete" | "error";
+ completed?: number;
+ total?: number;
+ label?: string;
+ error?: string;
+}
+
+export interface ExportResponse {
+ format: "json" | "md";
+ data: string;
+ filename: string;
+}
+
+export interface ApiError {
+ error: string;
+ code?: string;
+}