Add memoery recall feature to support debugging and memory insights
This commit is contained in:
parent
9daa846457
commit
7851a04b69
@ -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
13
recall-ui/index.html
Normal 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
28
recall-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
recall-ui/postcss.config.js
Normal file
6
recall-ui/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
5
recall-ui/public/favicon.svg
Normal file
5
recall-ui/public/favicon.svg
Normal 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
19
recall-ui/src/App.tsx
Normal 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
169
recall-ui/src/api/client.ts
Normal 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();
|
||||
96
recall-ui/src/components/Layout.tsx
Normal file
96
recall-ui/src/components/Layout.tsx
Normal 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
23
recall-ui/src/main.tsx
Normal 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>,
|
||||
);
|
||||
160
recall-ui/src/pages/BrowsePage.tsx
Normal file
160
recall-ui/src/pages/BrowsePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
recall-ui/src/pages/SearchPage.tsx
Normal file
164
recall-ui/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
recall-ui/src/styles/globals.css
Normal file
78
recall-ui/src/styles/globals.css
Normal 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
26
recall-ui/tsconfig.json
Normal 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
25
recall-ui/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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
86
src/cli/recall-cli.ts
Normal 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
2
src/recall/index.ts
Normal 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
112
src/recall/routes/files.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
131
src/recall/routes/memories.ts
Normal file
131
src/recall/routes/memories.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
39
src/recall/routes/search.ts
Normal file
39
src/recall/routes/search.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
31
src/recall/routes/status.ts
Normal file
31
src/recall/routes/status.ts
Normal 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
79
src/recall/routes/sync.ts
Normal 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
294
src/recall/server.ts
Normal 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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
162
src/recall/services/file-service.ts
Normal file
162
src/recall/services/file-service.ts
Normal 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);
|
||||
}
|
||||
329
src/recall/services/memory-service.ts
Normal file
329
src/recall/services/memory-service.ts
Normal 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
105
src/recall/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user