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/utils/**",
|
||||||
"dist/logging/**",
|
"dist/logging/**",
|
||||||
"dist/memory/**",
|
"dist/memory/**",
|
||||||
|
"dist/recall/**",
|
||||||
|
"dist/recall-ui/**",
|
||||||
"dist/markdown/**",
|
"dist/markdown/**",
|
||||||
"dist/node-host/**",
|
"dist/node-host/**",
|
||||||
"dist/pairing/**",
|
"dist/pairing/**",
|
||||||
@ -77,7 +79,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/run-node.mjs",
|
"dev": "node scripts/run-node.mjs",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"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:list": "node scripts/docs-list.js",
|
||||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
@ -88,6 +90,8 @@
|
|||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
"ui:dev": "node scripts/ui.js dev",
|
"ui:dev": "node scripts/ui.js dev",
|
||||||
"ui:build": "node scripts/ui.js build",
|
"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",
|
"start": "node scripts/run-node.mjs",
|
||||||
"clawdbot": "node scripts/run-node.mjs",
|
"clawdbot": "node scripts/run-node.mjs",
|
||||||
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
"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);
|
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) {
|
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