openclaw/src/recall/services/memory-service.ts

330 lines
9.4 KiB
TypeScript

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