Add memoery recall feature to support debugging and memory insights

This commit is contained in:
josharsh 2026-01-27 19:00:24 +05:30
parent 9daa846457
commit 7851a04b69
26 changed files with 2195 additions and 1 deletions

View File

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

13
recall-ui/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recall - Memory Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
recall-ui/package.json Normal file
View File

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

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a10 10 0 1 0 10 10H12V2z"/>
<path d="M12 12 8 8"/>
<circle cx="12" cy="12" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

19
recall-ui/src/App.tsx Normal file
View File

@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/browse" replace />} />
<Route path="browse" element={<BrowsePage />} />
<Route path="search" element={<SearchPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}

169
recall-ui/src/api/client.ts Normal file
View File

@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<T>;
}
async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/api/status");
}
async getMemories(params?: {
page?: number;
limit?: number;
source?: MemorySource;
}): Promise<MemoriesListResponse> {
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<MemoriesListResponse>(`/api/memories${query ? `?${query}` : ""}`);
}
async search(query: string, options?: { maxResults?: number; minScore?: number }): Promise<SearchResponse> {
return this.request<SearchResponse>("/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<void> {
// 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();

View File

@ -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 (
<div className="flex min-h-screen bg-[hsl(var(--background))]">
{/* Sidebar */}
<aside className="w-64 border-r border-[hsl(var(--border))] bg-[hsl(var(--card))] flex flex-col">
<div className="p-4 border-b border-[hsl(var(--border))]">
<Link to="/" className="flex items-center gap-2">
<Brain className="w-6 h-6 text-[hsl(var(--primary))]" />
<span className="font-semibold text-lg">Recall</span>
</Link>
<p className="text-xs text-[hsl(var(--muted-foreground))] mt-1">Memory Manager</p>
</div>
<nav className="flex-1 p-2">
<NavLink
to="/browse"
className={({ isActive }) =>
`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))]"
}`
}
>
<FolderOpen className="w-4 h-4" />
Browse
</NavLink>
<NavLink
to="/search"
className={({ isActive }) =>
`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 className="w-4 h-4" />
Search
</NavLink>
</nav>
{/* Status Bar */}
<div className="p-4 border-t border-[hsl(var(--border))]">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading...
</div>
) : status ? (
<div className="space-y-1 text-xs text-[hsl(var(--muted-foreground))]">
<div className="flex justify-between">
<span>Files:</span>
<span className="font-mono">{status.files}</span>
</div>
<div className="flex justify-between">
<span>Chunks:</span>
<span className="font-mono">{status.chunks}</span>
</div>
<div className="flex justify-between">
<span>Provider:</span>
<span className="font-mono truncate max-w-[100px]" title={status.provider}>
{status.provider}
</span>
</div>
{status.dirty && (
<div className="flex items-center gap-1 text-amber-500">
<RefreshCw className="w-3 h-3" />
<span>Index out of date</span>
</div>
)}
</div>
) : (
<div className="text-xs text-red-500">Unable to connect</div>
)}
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

23
recall-ui/src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@ -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<MemorySource | undefined>();
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 (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Memory Browser</h1>
<p className="text-sm text-[hsl(var(--muted-foreground))] mt-1">
Browse and manage your AI's memories
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => 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"
>
<RefreshCw className={`w-4 h-4 ${syncMutation.isPending ? "animate-spin" : ""}`} />
Sync
</button>
<button
onClick={() => 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"
>
<Download className="w-4 h-4" />
Export
</button>
</div>
</div>
{/* Source filters */}
<div className="flex gap-2 mb-6">
<button
onClick={() => 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
</button>
{status?.sources.map((source) => (
<button
key={source}
onClick={() => 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" ? <FileText className="w-3.5 h-3.5" /> : <Database className="w-3.5 h-3.5" />}
{source}
</button>
))}
</div>
{/* Stats cards */}
{status && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{status.sourceCounts.map((sc) => (
<div
key={sc.source}
className={`p-4 rounded-lg border ${
selectedSource === sc.source
? "border-[hsl(var(--primary))] bg-[hsl(var(--primary))]/5"
: "border-[hsl(var(--border))] bg-[hsl(var(--card))]"
}`}
>
<div className="flex items-center gap-2 mb-2">
{sc.source === "memory" ? (
<FileText className="w-4 h-4 text-[hsl(var(--muted-foreground))]" />
) : (
<Database className="w-4 h-4 text-[hsl(var(--muted-foreground))]" />
)}
<span className="font-medium capitalize">{sc.source}</span>
</div>
<div className="text-2xl font-semibold">{sc.chunks}</div>
<div className="text-xs text-[hsl(var(--muted-foreground))]">
{sc.files} {sc.files === 1 ? "file" : "files"}
</div>
</div>
))}
</div>
)}
{/* Info panel */}
<div className="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-[hsl(var(--secondary))]">
<FileText className="w-6 h-6 text-[hsl(var(--muted-foreground))]" />
</div>
<div>
<h3 className="font-medium mb-1">Memory Files Location</h3>
<p className="text-sm text-[hsl(var(--muted-foreground))] mb-3">
Memories are stored in your workspace directory:
</p>
<code className="text-xs bg-[hsl(var(--secondary))] px-2 py-1 rounded">
{status?.workspaceDir || "Loading..."}
</code>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]">
<ChevronRight className="w-4 h-4" />
<code className="bg-[hsl(var(--secondary))] px-1.5 py-0.5 rounded text-xs">MEMORY.md</code>
<span>- Main memory file</span>
</div>
<div className="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]">
<ChevronRight className="w-4 h-4" />
<code className="bg-[hsl(var(--secondary))] px-1.5 py-0.5 rounded text-xs">memory/*.md</code>
<span>- Additional memory files</span>
</div>
</div>
</div>
</div>
</div>
{/* Search hint */}
<div className="mt-6 text-center text-sm text-[hsl(var(--muted-foreground))]">
Use the <strong>Search</strong> tab to find specific memories using semantic search.
</div>
</div>
);
}

View File

@ -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<SearchResult[]>([]);
const [copiedId, setCopiedId] = useState<string | null>(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 (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Search Memories</h1>
<p className="text-sm text-[hsl(var(--muted-foreground))] mt-1">
Search your AI's memories using natural language
</p>
</div>
{/* Search form */}
<form onSubmit={handleSearch} className="mb-6">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[hsl(var(--muted-foreground))]" />
<input
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={!query.trim() || searchMutation.isPending}
className="px-4 py-2.5 bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{searchMutation.isPending ? "Searching..." : "Search"}
</button>
</div>
</form>
{/* Results */}
{searchMutation.isError && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 text-sm mb-6">
Search failed: {searchMutation.error?.message || "Unknown error"}
</div>
)}
{results.length > 0 && (
<div className="space-y-3">
<div className="text-sm text-[hsl(var(--muted-foreground))]">
Found {results.length} result{results.length === 1 ? "" : "s"}
</div>
{results.map((result) => (
<div
key={result.id}
className="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-[hsl(var(--secondary))] border-b border-[hsl(var(--border))]">
<div className="flex items-center gap-2">
{result.source === "memory" ? (
<FileText className="w-4 h-4 text-[hsl(var(--muted-foreground))]" />
) : (
<Database className="w-4 h-4 text-[hsl(var(--muted-foreground))]" />
)}
<span className="text-sm font-mono text-[hsl(var(--muted-foreground))]">
{result.path}
</span>
<span className="text-xs text-[hsl(var(--muted-foreground))]">
L{result.startLine}-{result.endLine}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded ${
result.score >= 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)}
</span>
<button
onClick={() => 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 ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Content */}
<div className="p-4">
<pre className="text-sm whitespace-pre-wrap font-mono text-[hsl(var(--foreground))] leading-relaxed">
{result.snippet}
</pre>
</div>
</div>
))}
</div>
)}
{/* Empty state */}
{!searchMutation.isPending && results.length === 0 && query && !searchMutation.isError && (
<div className="text-center py-12">
<Search className="w-12 h-12 mx-auto text-[hsl(var(--muted-foreground))] mb-4" />
<h3 className="font-medium mb-2">No results found</h3>
<p className="text-sm text-[hsl(var(--muted-foreground))]">
Try a different search term or add more memories.
</p>
</div>
)}
{/* Initial state */}
{!query && results.length === 0 && (
<div className="text-center py-12">
<Search className="w-12 h-12 mx-auto text-[hsl(var(--muted-foreground))] mb-4" />
<h3 className="font-medium mb-2">Search Your Memories</h3>
<p className="text-sm text-[hsl(var(--muted-foreground))] max-w-md mx-auto">
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.
</p>
</div>
)}
</div>
);
}

View File

@ -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);
}

26
recall-ui/tsconfig.json Normal file
View File

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

25
recall-ui/vite.config.ts Normal file
View File

@ -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,
},
});

View File

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

86
src/cli/recall-cli.ts Normal file
View File

@ -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>", "Port to listen on", (v) => Number.parseInt(v, 10), DEFAULT_PORT)
.option("--agent <id>", "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(() => {});
});
}

2
src/recall/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { createRecallServer, type RecallServer, type RecallServerOptions } from "./server.js";
export type * from "./types.js";

112
src/recall/routes/files.ts Normal file
View File

@ -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<Response> => {
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<Response> => {
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<Response> => {
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);
}
};
}

View File

@ -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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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);
}
};
}

View File

@ -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<Response> => {
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<SearchRequest>();
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);
}
};
}

View File

@ -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<Response> => {
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);
}
};
}

79
src/recall/routes/sync.ts Normal file
View File

@ -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<Response> => {
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<Response> => {
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);
}
};
}

294
src/recall/server.ts Normal file
View File

@ -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<void>;
stop(): Promise<void>;
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<RecallServer> {
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<void> {
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<Buffer>((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<void> {
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}`;
},
};
}

View File

@ -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<boolean> {
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);
}

View File

@ -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<void> {
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<SearchResult[]> {
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<MemoryChunkRecord | null> {
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<FileInfo[]> {
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<void> {
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<string> {
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<void> {
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<MemoryService> {
const service = new MemoryService({ cfg, agentId });
await service.init();
return service;
}

105
src/recall/types.ts Normal file
View File

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