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 */} + + + {/* 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 +

+
+
+ + +
+
+ + {/* Source filters */} +
+ + {status?.sources.map((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 */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Search memories... (e.g., 'user preferences', 'project setup')" + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--ring))] focus:border-transparent" + /> +
+ +
+
+ + {/* 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)} + + +
+
+ + {/* Content */} +
+
+                  {result.snippet}
+                
+
+
+ ))} +
+ )} + + {/* 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; +}