From 28868557063d0cc7ec56c403b0e698744ddd9510 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 8 Mar 2026 12:47:38 -0700 Subject: [PATCH] feat: add first-class memory management Expose gateway memory APIs for status, init, files, search, and sync. Align pi-memory-md with project-scoped, local-first memory behavior. Co-authored-by: Codex --- .../coding-agent/src/core/gateway/memory.ts | 945 ++++++++++++++++++ .../coding-agent/src/core/gateway/runtime.ts | 114 +++ packages/pi-memory-md/memory-md.ts | 232 ++++- packages/pi-memory-md/tools.ts | 220 +++- 4 files changed, 1437 insertions(+), 74 deletions(-) create mode 100644 packages/coding-agent/src/core/gateway/memory.ts diff --git a/packages/coding-agent/src/core/gateway/memory.ts b/packages/coding-agent/src/core/gateway/memory.ts new file mode 100644 index 0000000..709ce4f --- /dev/null +++ b/packages/coding-agent/src/core/gateway/memory.ts @@ -0,0 +1,945 @@ +import { + type Dirent, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { execCommand } from "../exec.js"; +import type { SettingsManager } from "../settings-manager.js"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { HttpError } from "./internal-types.js"; + +export interface MemoryFrontmatter { + description: string; + limit?: number; + tags?: string[]; + created?: string; + updated?: string; +} + +export interface MemoryMdSettings { + enabled?: boolean; + repoUrl?: string; + localPath?: string; + autoSync?: { + onSessionStart?: boolean; + }; + injection?: "system-prompt" | "message-append"; + systemPrompt?: { + maxTokens?: number; + includeProjects?: string[]; + }; +} + +export interface MemoryStatus { + enabled: boolean; + cwd: string; + project: string; + directory: string; + localPath: string; + repoUrl: string | null; + repoConfigured: boolean; + repositoryReady: boolean; + initialized: boolean; + dirty: boolean | null; + fileCount: number; +} + +export interface MemoryFileSummary { + path: string; + description: string | null; + tags: string[]; + created?: string; + updated?: string; + valid: boolean; +} + +export interface MemoryFileRecord { + path: string; + frontmatter: MemoryFrontmatter; + content: string; +} + +export interface MemorySearchResult { + path: string; + match: string; + description: string; + tags: string[]; +} + +export interface MemorySyncResult { + success: boolean; + message: string; + configured: boolean; + initialized: boolean; + dirty: boolean | null; + updated?: boolean; + committed?: boolean; +} + +export type MemorySearchScope = "content" | "tags" | "description"; +export type MemorySyncAction = "pull" | "push" | "status"; + +const DEFAULT_LOCAL_PATH = join(homedir(), ".pi", "memory-md"); +const DEFAULT_MEMORY_SETTINGS: MemoryMdSettings = { + enabled: true, + repoUrl: "", + localPath: DEFAULT_LOCAL_PATH, + autoSync: { onSessionStart: true }, + injection: "message-append", + systemPrompt: { + maxTokens: 10000, + includeProjects: ["current"], + }, +}; + +function getCurrentDate(): string { + return new Date().toISOString().split("T")[0] ?? ""; +} + +function getLegacyProjectDirName(cwd: string): string { + return basename(cwd); +} + +function getProjectDirName(cwd: string): string { + const projectName = getLegacyProjectDirName(cwd); + const hash = createHash("sha256") + .update(resolve(cwd)) + .digest("hex") + .slice(0, 12); + return `${projectName}-${hash}`; +} + +function getMemoryDirCandidates( + settings: MemoryMdSettings, + cwd: string, +): { + preferred: string; + legacy: string; +} { + const basePath = settings.localPath ?? DEFAULT_LOCAL_PATH; + return { + preferred: join(basePath, getProjectDirName(cwd)), + legacy: join(basePath, getLegacyProjectDirName(cwd)), + }; +} + +function migrateLegacyMemoryDir(preferred: string, legacy: string): string { + try { + renameSync(legacy, preferred); + return preferred; + } catch { + return legacy; + } +} + +function normalizePathSeparators(value: string): string { + return value.replaceAll("\\", "/"); +} + +function expandPath(value: string): string { + if (!value.startsWith("~")) { + return value; + } + return join(homedir(), value.slice(1)); +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readScopedMemorySettings( + settings: Record, +): MemoryMdSettings { + const scoped = asRecord(settings["pi-memory-md"]); + return scoped ? (scoped as MemoryMdSettings) : {}; +} + +function getMemorySettings(settingsManager: SettingsManager): MemoryMdSettings { + const globalSettings = readScopedMemorySettings( + settingsManager.getGlobalSettings() as Record, + ); + const projectSettings = readScopedMemorySettings( + settingsManager.getProjectSettings() as Record, + ); + const merged: MemoryMdSettings = { + ...DEFAULT_MEMORY_SETTINGS, + ...globalSettings, + ...projectSettings, + autoSync: { + ...DEFAULT_MEMORY_SETTINGS.autoSync, + ...globalSettings.autoSync, + ...projectSettings.autoSync, + }, + systemPrompt: { + ...DEFAULT_MEMORY_SETTINGS.systemPrompt, + ...globalSettings.systemPrompt, + ...projectSettings.systemPrompt, + }, + }; + if (merged.localPath) { + merged.localPath = expandPath(merged.localPath); + } + return merged; +} + +function getMemoryDir(settings: MemoryMdSettings, cwd: string): string { + const { preferred, legacy } = getMemoryDirCandidates(settings, cwd); + if (existsSync(preferred)) { + return preferred; + } + if (existsSync(legacy)) { + return migrateLegacyMemoryDir(preferred, legacy); + } + return preferred; +} + +function getProjectRepoPath(settings: MemoryMdSettings, cwd: string): string { + const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH; + return normalizePathSeparators(relative(localPath, getMemoryDir(settings, cwd))); +} + +function validateFrontmatter(frontmatter: Record): { + valid: boolean; + error?: string; +} { + if ( + typeof frontmatter.description !== "string" || + frontmatter.description.trim().length === 0 + ) { + return { + valid: false, + error: "Frontmatter must contain a non-empty description", + }; + } + if ( + frontmatter.limit !== undefined && + (typeof frontmatter.limit !== "number" || frontmatter.limit <= 0) + ) { + return { + valid: false, + error: "Frontmatter limit must be a positive number", + }; + } + if (frontmatter.tags !== undefined) { + if (!Array.isArray(frontmatter.tags)) { + return { + valid: false, + error: "Frontmatter tags must be an array of strings", + }; + } + if ( + frontmatter.tags.some((tag) => typeof tag !== "string" || tag.length === 0) + ) { + return { + valid: false, + error: "Frontmatter tags must contain only non-empty strings", + }; + } + } + return { valid: true }; +} + +function normalizeFrontmatter(frontmatter: Record): MemoryFrontmatter { + return { + description: frontmatter.description as string, + ...(typeof frontmatter.limit === "number" + ? { limit: frontmatter.limit } + : {}), + ...(Array.isArray(frontmatter.tags) + ? { + tags: frontmatter.tags.filter( + (tag): tag is string => typeof tag === "string", + ), + } + : {}), + ...(typeof frontmatter.created === "string" + ? { created: frontmatter.created } + : {}), + ...(typeof frontmatter.updated === "string" + ? { updated: frontmatter.updated } + : {}), + }; +} + +function readMemoryFile(filePath: string): MemoryFileRecord | null { + try { + const content = readFileSync(filePath, "utf8"); + const parsed = parseFrontmatter>(content); + const validation = validateFrontmatter(parsed.frontmatter); + if (!validation.valid) { + return null; + } + return { + path: filePath, + frontmatter: normalizeFrontmatter(parsed.frontmatter), + content: parsed.body, + }; + } catch { + return null; + } +} + +function escapeYamlString(value: string): string { + return JSON.stringify(value); +} + +function serializeFrontmatter(frontmatter: MemoryFrontmatter): string { + const lines = ["---", `description: ${escapeYamlString(frontmatter.description)}`]; + if (typeof frontmatter.limit === "number") { + lines.push(`limit: ${frontmatter.limit}`); + } + if (frontmatter.tags && frontmatter.tags.length > 0) { + lines.push("tags:"); + for (const tag of frontmatter.tags) { + lines.push(` - ${escapeYamlString(tag)}`); + } + } + if (frontmatter.created) { + lines.push(`created: ${escapeYamlString(frontmatter.created)}`); + } + if (frontmatter.updated) { + lines.push(`updated: ${escapeYamlString(frontmatter.updated)}`); + } + lines.push("---"); + return lines.join("\n"); +} + +function writeMemoryFile( + filePath: string, + content: string, + frontmatter: MemoryFrontmatter, +): void { + mkdirSync(dirname(filePath), { recursive: true }); + const normalizedContent = content.replace(/\r\n/g, "\n"); + const body = normalizedContent.endsWith("\n") + ? normalizedContent + : `${normalizedContent}\n`; + const output = `${serializeFrontmatter(frontmatter)}\n\n${body}`; + writeFileSync(filePath, output, "utf8"); +} + +function listMemoryFiles(memoryDir: string): string[] { + if (!existsSync(memoryDir)) { + return []; + } + const files: string[] = []; + const stack = [memoryDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) { + continue; + } + let entries: Dirent[]; + try { + entries = readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(fullPath); + } + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function ensureDirectoryStructure(memoryDir: string): void { + const directories = [ + join(memoryDir, "core", "user"), + join(memoryDir, "core", "project"), + join(memoryDir, "reference"), + ]; + for (const directory of directories) { + mkdirSync(directory, { recursive: true }); + } +} + +function ensureDefaultFiles(memoryDir: string): void { + const identityPath = join(memoryDir, "core", "user", "identity.md"); + if (!existsSync(identityPath)) { + writeMemoryFile( + identityPath, + "# User Identity\n\nCustomize this file with your information.", + { + description: "User identity and background", + tags: ["user", "identity"], + created: getCurrentDate(), + }, + ); + } + + const preferencesPath = join(memoryDir, "core", "user", "prefer.md"); + if (!existsSync(preferencesPath)) { + writeMemoryFile( + preferencesPath, + "# User Preferences\n\n## Communication Style\n- Be concise\n- Show code examples\n\n## Code Style\n- 2 space indentation\n- Prefer const over var\n- Functional programming preferred", + { + description: "User habits and code style preferences", + tags: ["user", "preferences"], + created: getCurrentDate(), + }, + ); + } +} + +function resolveWithinMemoryDir(memoryDir: string, relativePath: string): string { + const trimmed = relativePath.trim(); + if (trimmed.length === 0) { + throw new HttpError(400, "Memory path is required"); + } + const resolvedPath = resolve(memoryDir, trimmed); + const resolvedRoot = resolve(memoryDir); + if ( + resolvedPath !== resolvedRoot && + !resolvedPath.startsWith(`${resolvedRoot}${sep}`) + ) { + throw new HttpError(400, `Memory path escapes root: ${relativePath}`); + } + return resolvedPath; +} + +function summarizeMemoryFile( + memoryDir: string, + filePath: string, +): MemoryFileSummary { + const relativePath = normalizePathSeparators(relative(memoryDir, filePath)); + const memoryFile = readMemoryFile(filePath); + if (!memoryFile) { + return { + path: relativePath, + description: null, + tags: [], + valid: false, + }; + } + return { + path: relativePath, + description: memoryFile.frontmatter.description, + tags: memoryFile.frontmatter.tags ?? [], + created: memoryFile.frontmatter.created, + updated: memoryFile.frontmatter.updated, + valid: true, + }; +} + +function readMemoryFileOrThrow( + memoryDir: string, + relativePath: string, +): MemoryFileRecord { + const fullPath = resolveWithinMemoryDir(memoryDir, relativePath); + if (!existsSync(fullPath)) { + throw new HttpError(404, `Memory file not found: ${relativePath}`); + } + const memoryFile = readMemoryFile(fullPath); + if (!memoryFile) { + throw new HttpError(422, `Invalid memory file: ${relativePath}`); + } + return { + ...memoryFile, + path: normalizePathSeparators(relative(memoryDir, fullPath)), + }; +} + +async function runGit( + cwd: string, + ...args: string[] +): Promise<{ success: boolean; stdout: string; stderr: string }> { + const result = await execCommand("git", args, cwd, { timeout: 30_000 }); + return { + success: result.code === 0 && !result.killed, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +function getRepoName(settings: MemoryMdSettings): string { + if (!settings.repoUrl) { + return "memory-md"; + } + const match = settings.repoUrl.match(/\/([^/]+?)(\.git)?$/); + return match?.[1] ?? "memory-md"; +} + +async function getRepositoryDirtyState( + localPath: string, + projectPath?: string, +): Promise { + if (!existsSync(join(localPath, ".git"))) { + return null; + } + const args = projectPath + ? ["status", "--porcelain", "--", projectPath] + : ["status", "--porcelain"]; + const result = await runGit(localPath, ...args); + if (!result.success) { + return null; + } + return result.stdout.trim().length > 0; +} + +async function syncRepository( + settings: MemoryMdSettings, +): Promise<{ success: boolean; message: string; updated?: boolean }> { + const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH; + const repoUrl = settings.repoUrl?.trim() ?? ""; + + if (!repoUrl) { + return { + success: false, + message: "Memory repo URL is not configured", + }; + } + + if (existsSync(localPath)) { + if (!existsSync(join(localPath, ".git"))) { + let existingEntries: string[]; + try { + existingEntries = readdirSync(localPath); + } catch { + return { + success: false, + message: `Path exists but is not a directory: ${localPath}`, + }; + } + if (existingEntries.length === 0) { + const cloneIntoEmptyDir = await runGit(localPath, "clone", repoUrl, "."); + if (!cloneIntoEmptyDir.success) { + return { + success: false, + message: + cloneIntoEmptyDir.stderr.trim() || + "Clone failed. Check repo URL and auth.", + }; + } + return { + success: true, + message: `Cloned ${getRepoName(settings)} successfully`, + updated: true, + }; + } + return { + success: false, + message: `Directory exists but is not a git repo: ${localPath}`, + }; + } + const pullResult = await runGit(localPath, "pull", "--rebase", "--autostash"); + if (!pullResult.success) { + return { + success: false, + message: + pullResult.stderr.trim() || "Pull failed. Check repository state.", + }; + } + const updated = + pullResult.stdout.includes("Updating") || + pullResult.stdout.includes("Fast-forward"); + return { + success: true, + message: updated + ? `Pulled latest changes from ${getRepoName(settings)}` + : `${getRepoName(settings)} is already up to date`, + updated, + }; + } + + mkdirSync(dirname(localPath), { recursive: true }); + const cloneResult = await runGit( + dirname(localPath), + "clone", + repoUrl, + basename(localPath), + ); + if (!cloneResult.success) { + return { + success: false, + message: cloneResult.stderr.trim() || "Clone failed. Check repo URL and auth.", + }; + } + return { + success: true, + message: `Cloned ${getRepoName(settings)} successfully`, + updated: true, + }; +} + +export async function getMemoryStatus( + settingsManager: SettingsManager, + cwd: string, +): Promise { + const settings = getMemorySettings(settingsManager); + const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH; + const memoryDir = getMemoryDir(settings, cwd); + const initialized = existsSync(join(memoryDir, "core", "user")); + const fileCount = listMemoryFiles(memoryDir).length; + const dirty = await getRepositoryDirtyState( + localPath, + getProjectRepoPath(settings, cwd), + ); + return { + enabled: settings.enabled ?? true, + cwd, + project: basename(cwd), + directory: memoryDir, + localPath, + repoUrl: settings.repoUrl?.trim() || null, + repoConfigured: Boolean(settings.repoUrl?.trim()), + repositoryReady: existsSync(join(localPath, ".git")), + initialized, + dirty, + fileCount, + }; +} + +export async function initializeMemory( + settingsManager: SettingsManager, + cwd: string, + options: { force?: boolean } = {}, +): Promise<{ + ok: true; + created: boolean; + message: string; + memory: MemoryStatus; +}> { + const settings = getMemorySettings(settingsManager); + const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH; + const memoryDir = getMemoryDir(settings, cwd); + const initialized = existsSync(join(memoryDir, "core", "user")); + + if (settings.repoUrl?.trim()) { + const repositoryReady = existsSync(join(localPath, ".git")); + if (!initialized || options.force || !repositoryReady) { + const syncResult = await syncRepository(settings); + if (!syncResult.success) { + throw new HttpError(409, syncResult.message); + } + } + } else { + mkdirSync(localPath, { recursive: true }); + } + + ensureDirectoryStructure(memoryDir); + ensureDefaultFiles(memoryDir); + + return { + ok: true, + created: !initialized, + message: + settings.repoUrl?.trim() + ? initialized + ? "Memory repository refreshed and project memory verified" + : "Memory repository initialized for this project" + : initialized + ? "Local memory verified" + : "Local memory initialized", + memory: await getMemoryStatus(settingsManager, cwd), + }; +} + +export async function listProjectMemoryFiles( + settingsManager: SettingsManager, + cwd: string, + directory?: string, +): Promise<{ + directory: string; + files: MemoryFileSummary[]; +}> { + const settings = getMemorySettings(settingsManager); + const memoryDir = getMemoryDir(settings, cwd); + const searchDir = directory + ? resolveWithinMemoryDir(memoryDir, directory) + : memoryDir; + const files = listMemoryFiles(searchDir).map((filePath) => + summarizeMemoryFile(memoryDir, filePath), + ); + return { + directory: directory?.trim() || "", + files, + }; +} + +export async function readProjectMemoryFile( + settingsManager: SettingsManager, + cwd: string, + relativePath: string, +): Promise<{ file: MemoryFileRecord }> { + const settings = getMemorySettings(settingsManager); + const memoryDir = getMemoryDir(settings, cwd); + return { + file: readMemoryFileOrThrow(memoryDir, relativePath), + }; +} + +export async function writeProjectMemoryFile( + settingsManager: SettingsManager, + cwd: string, + params: { + path: string; + content: string; + description: string; + tags?: string[]; + }, +): Promise<{ ok: true; file: MemoryFileRecord }> { + const relativePath = params.path.trim(); + if (!relativePath.endsWith(".md")) { + throw new HttpError(400, "Memory files must use the .md extension"); + } + if (params.description.trim().length === 0) { + throw new HttpError(400, "Memory description is required"); + } + + const settings = getMemorySettings(settingsManager); + const memoryDir = getMemoryDir(settings, cwd); + const fullPath = resolveWithinMemoryDir(memoryDir, relativePath); + if (existsSync(fullPath) && statSync(fullPath).isDirectory()) { + throw new HttpError(400, `Memory path points to a directory: ${relativePath}`); + } + const existing = existsSync(fullPath) ? readMemoryFile(fullPath) : null; + + const hasTagsInput = params.tags !== undefined; + const tags = (params.tags ?? []).map((tag) => tag.trim()).filter(Boolean); + const frontmatter: MemoryFrontmatter = { + description: params.description.trim(), + created: existing?.frontmatter.created ?? getCurrentDate(), + updated: getCurrentDate(), + ...(existing?.frontmatter.limit !== undefined + ? { limit: existing.frontmatter.limit } + : {}), + ...(hasTagsInput + ? { tags } + : existing?.frontmatter.tags + ? { tags: existing.frontmatter.tags } + : {}), + }; + + writeMemoryFile(fullPath, params.content, frontmatter); + + return { + ok: true, + file: { + path: normalizePathSeparators(relative(memoryDir, fullPath)), + frontmatter, + content: params.content.replace(/\r\n/g, "\n"), + }, + }; +} + +export async function searchProjectMemory( + settingsManager: SettingsManager, + cwd: string, + query: string, + searchIn: MemorySearchScope, +): Promise<{ results: MemorySearchResult[] }> { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) { + throw new HttpError(400, "Memory search query is required"); + } + + const settings = getMemorySettings(settingsManager); + const memoryDir = getMemoryDir(settings, cwd); + const results: MemorySearchResult[] = []; + + for (const filePath of listMemoryFiles(memoryDir)) { + const memoryFile = readMemoryFile(filePath); + if (!memoryFile) { + continue; + } + const relativePath = normalizePathSeparators(relative(memoryDir, filePath)); + if (searchIn === "content") { + if (!memoryFile.content.toLowerCase().includes(normalizedQuery)) { + continue; + } + const match = + memoryFile.content + .split("\n") + .find((line) => line.toLowerCase().includes(normalizedQuery)) ?? + memoryFile.content.slice(0, 120); + results.push({ + path: relativePath, + match, + description: memoryFile.frontmatter.description, + tags: memoryFile.frontmatter.tags ?? [], + }); + continue; + } + + if (searchIn === "tags") { + const tags = memoryFile.frontmatter.tags ?? []; + if (!tags.some((tag) => tag.toLowerCase().includes(normalizedQuery))) { + continue; + } + results.push({ + path: relativePath, + match: `Tags: ${tags.join(", ")}`, + description: memoryFile.frontmatter.description, + tags, + }); + continue; + } + + if ( + memoryFile.frontmatter.description.toLowerCase().includes(normalizedQuery) + ) { + results.push({ + path: relativePath, + match: memoryFile.frontmatter.description, + description: memoryFile.frontmatter.description, + tags: memoryFile.frontmatter.tags ?? [], + }); + } + } + + return { results }; +} + +export async function syncProjectMemory( + settingsManager: SettingsManager, + cwd: string, + action: MemorySyncAction, +): Promise { + const settings = getMemorySettings(settingsManager); + const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH; + const projectPath = getProjectRepoPath(settings, cwd); + const configured = Boolean(settings.repoUrl?.trim()); + const repositoryReady = existsSync(join(localPath, ".git")); + const initialized = existsSync(join(getMemoryDir(settings, cwd), "core", "user")); + + if (action === "status") { + const dirty = await getRepositoryDirtyState(localPath, projectPath); + return { + success: repositoryReady, + message: repositoryReady + ? dirty + ? "Memory repository has uncommitted changes" + : "Memory repository is clean" + : configured + ? "Memory repository is not initialized" + : "Memory repository is not configured", + configured, + initialized, + dirty, + }; + } + + if (!configured) { + return { + success: false, + message: "Memory repo URL is not configured", + configured, + initialized, + dirty: null, + }; + } + + if (action === "pull") { + const result = await syncRepository(settings); + return { + success: result.success, + message: result.message, + configured, + initialized, + dirty: await getRepositoryDirtyState(localPath, projectPath), + updated: result.updated, + }; + } + + if (!repositoryReady) { + const cloned = await syncRepository(settings); + if (!cloned.success) { + return { + success: false, + message: cloned.message, + configured, + initialized, + dirty: null, + }; + } + } + + const statusResult = await runGit( + localPath, + "status", + "--porcelain", + "--", + projectPath, + ); + if (!statusResult.success) { + return { + success: false, + message: statusResult.stderr.trim() || "Failed to inspect memory repository", + configured, + initialized, + dirty: null, + }; + } + + const hasChanges = statusResult.stdout.trim().length > 0; + if (hasChanges) { + const addResult = await runGit(localPath, "add", "-A", "--", projectPath); + if (!addResult.success) { + return { + success: false, + message: addResult.stderr.trim() || "Failed to stage memory changes", + configured, + initialized, + dirty: true, + }; + } + + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + const commitResult = await runGit( + localPath, + "commit", + "-m", + `Update memory for ${basename(cwd)} - ${timestamp}`, + "--only", + "--", + projectPath, + ); + if (!commitResult.success) { + return { + success: false, + message: + commitResult.stderr.trim() || "Failed to commit memory changes", + configured, + initialized, + dirty: true, + }; + } + } + + const pushResult = await runGit(localPath, "push"); + return { + success: pushResult.success, + message: pushResult.success + ? hasChanges + ? "Committed and pushed memory changes" + : "No memory changes to push" + : pushResult.stderr.trim() || "Failed to push memory changes", + configured, + initialized, + dirty: await getRepositoryDirtyState(localPath, projectPath), + committed: hasChanges, + }; +} diff --git a/packages/coding-agent/src/core/gateway/runtime.ts b/packages/coding-agent/src/core/gateway/runtime.ts index 91491da..91ba6f7 100644 --- a/packages/coding-agent/src/core/gateway/runtime.ts +++ b/packages/coding-agent/src/core/gateway/runtime.ts @@ -29,6 +29,15 @@ import type { HistoryPart, ModelInfo, } from "./types.js"; +import { + getMemoryStatus, + initializeMemory, + listProjectMemoryFiles, + readProjectMemoryFile, + searchProjectMemory, + syncProjectMemory, + writeProjectMemoryFile, +} from "./memory.js"; import type { Settings } from "../settings-manager.js"; import { createVercelStreamListener, @@ -624,6 +633,111 @@ export class GatewayRuntime { return; } + if (method === "GET" && path === "/memory/status") { + const memory = await getMemoryStatus( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + ); + this.writeJson(response, 200, { memory }); + return; + } + + if (method === "POST" && path === "/memory/init") { + const body = await this.readJsonBody(request); + const result = await initializeMemory( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + { force: body.force === true }, + ); + this.writeJson(response, 200, result); + return; + } + + if (method === "GET" && path === "/memory/files") { + const directory = url.searchParams.get("directory") ?? undefined; + const result = await listProjectMemoryFiles( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + directory, + ); + this.writeJson(response, 200, result); + return; + } + + if (method === "GET" && path === "/memory/file") { + const filePath = url.searchParams.get("path"); + if (!filePath) { + this.writeJson(response, 400, { error: "Missing memory file path" }); + return; + } + const result = await readProjectMemoryFile( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + filePath, + ); + this.writeJson(response, 200, result); + return; + } + + if (method === "POST" && path === "/memory/file") { + const body = await this.readJsonBody(request); + const filePath = typeof body.path === "string" ? body.path : ""; + const content = typeof body.content === "string" ? body.content : ""; + const description = + typeof body.description === "string" ? body.description : ""; + const tags = Array.isArray(body.tags) + ? body.tags.filter((tag): tag is string => typeof tag === "string") + : undefined; + const result = await writeProjectMemoryFile( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + { + path: filePath, + content, + description, + tags, + }, + ); + this.writeJson(response, 200, result); + return; + } + + if (method === "POST" && path === "/memory/search") { + const body = await this.readJsonBody(request); + const query = typeof body.query === "string" ? body.query : ""; + const searchIn = + body.searchIn === "content" || + body.searchIn === "tags" || + body.searchIn === "description" + ? body.searchIn + : "content"; + const result = await searchProjectMemory( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + query, + searchIn, + ); + this.writeJson(response, 200, result); + return; + } + + if (method === "POST" && path === "/memory/sync") { + const body = await this.readJsonBody(request); + const action = + body.action === "pull" || + body.action === "push" || + body.action === "status" + ? body.action + : "status"; + const result = await syncProjectMemory( + this.primarySession.settingsManager, + this.primarySession.sessionManager.getCwd(), + action, + ); + this.writeJson(response, 200, result); + return; + } + const sessionMatch = path.match( /^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset|chat|history|model|reload))?$/, ); diff --git a/packages/pi-memory-md/memory-md.ts b/packages/pi-memory-md/memory-md.ts index 6abc451..ec54077 100644 --- a/packages/pi-memory-md/memory-md.ts +++ b/packages/pi-memory-md/memory-md.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import type { @@ -61,7 +62,7 @@ export type ParsedFrontmatter = GrayMatterFile["data"]; const DEFAULT_LOCAL_PATH = path.join(os.homedir(), ".pi", "memory-md"); export function getCurrentDate(): string { - return new Date().toISOString().split("T")[0]; + return new Date().toISOString().split("T")[0] ?? ""; } function expandPath(p: string): string { @@ -71,12 +72,73 @@ function expandPath(p: string): string { return p; } +function getLegacyProjectDirName(cwd: string): string { + return path.basename(cwd); +} + +function getProjectDirName(cwd: string): string { + const projectName = getLegacyProjectDirName(cwd); + const hash = createHash("sha256") + .update(path.resolve(cwd)) + .digest("hex") + .slice(0, 12); + return `${projectName}-${hash}`; +} + +function migrateLegacyMemoryDir( + preferredDir: string, + legacyDir: string, +): string { + try { + fs.renameSync(legacyDir, preferredDir); + return preferredDir; + } catch (error) { + console.warn("Failed to migrate legacy memory dir:", error); + return legacyDir; + } +} + export function getMemoryDir( settings: MemoryMdSettings, ctx: ExtensionContext, ): string { const basePath = settings.localPath || DEFAULT_LOCAL_PATH; - return path.join(basePath, path.basename(ctx.cwd)); + const preferredDir = path.join(basePath, getProjectDirName(ctx.cwd)); + if (fs.existsSync(preferredDir)) { + return preferredDir; + } + + const legacyDir = path.join(basePath, getLegacyProjectDirName(ctx.cwd)); + if (fs.existsSync(legacyDir)) { + return migrateLegacyMemoryDir(preferredDir, legacyDir); + } + + return preferredDir; +} + +export function getProjectRepoPath( + settings: MemoryMdSettings, + ctx: ExtensionContext, +): string { + const basePath = settings.localPath || DEFAULT_LOCAL_PATH; + return path.relative(basePath, getMemoryDir(settings, ctx)).split(path.sep).join("/"); +} + +export function resolveMemoryPath( + settings: MemoryMdSettings, + ctx: ExtensionContext, + relativePath: string, +): string { + const memoryDir = getMemoryDir(settings, ctx); + const resolvedPath = path.resolve(memoryDir, relativePath.trim()); + const resolvedRoot = path.resolve(memoryDir); + if ( + resolvedPath !== resolvedRoot && + !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) + ) { + throw new Error(`Memory path escapes root: ${relativePath}`); + } + return resolvedPath; } function getRepoName(settings: MemoryMdSettings): string { @@ -85,7 +147,26 @@ function getRepoName(settings: MemoryMdSettings): string { return match ? match[1] : "memory-md"; } -function loadSettings(): MemoryMdSettings { +function loadScopedSettings(settingsPath: string): MemoryMdSettings { + if (!fs.existsSync(settingsPath)) { + return {}; + } + + try { + const content = fs.readFileSync(settingsPath, "utf-8"); + const parsed = JSON.parse(content); + const scoped = parsed["pi-memory-md"]; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return {}; + } + return scoped as MemoryMdSettings; + } catch (error) { + console.warn("Failed to load memory settings:", error); + return {}; + } +} + +function loadSettings(cwd?: string): MemoryMdSettings { const DEFAULT_SETTINGS: MemoryMdSettings = { enabled: true, repoUrl: "", @@ -104,27 +185,34 @@ function loadSettings(): MemoryMdSettings { "agent", "settings.json", ); - if (!fs.existsSync(globalSettings)) { - return DEFAULT_SETTINGS; + const projectSettings = cwd + ? path.join(cwd, ".pi", "settings.json") + : undefined; + const globalLoaded = loadScopedSettings(globalSettings); + const projectLoaded = projectSettings + ? loadScopedSettings(projectSettings) + : {}; + const loadedSettings = { + ...DEFAULT_SETTINGS, + ...globalLoaded, + ...projectLoaded, + autoSync: { + ...DEFAULT_SETTINGS.autoSync, + ...globalLoaded.autoSync, + ...projectLoaded.autoSync, + }, + systemPrompt: { + ...DEFAULT_SETTINGS.systemPrompt, + ...globalLoaded.systemPrompt, + ...projectLoaded.systemPrompt, + }, + }; + + if (loadedSettings.localPath) { + loadedSettings.localPath = expandPath(loadedSettings.localPath); } - try { - const content = fs.readFileSync(globalSettings, "utf-8"); - const parsed = JSON.parse(content); - const loadedSettings = { - ...DEFAULT_SETTINGS, - ...(parsed["pi-memory-md"] as MemoryMdSettings), - }; - - if (loadedSettings.localPath) { - loadedSettings.localPath = expandPath(loadedSettings.localPath); - } - - return loadedSettings; - } catch (error) { - console.warn("Failed to load memory settings:", error); - return DEFAULT_SETTINGS; - } + return loadedSettings; } /** @@ -165,6 +253,33 @@ export async function syncRepository( if (fs.existsSync(localPath)) { const gitDir = path.join(localPath, ".git"); if (!fs.existsSync(gitDir)) { + let existingEntries: string[]; + try { + existingEntries = fs.readdirSync(localPath); + } catch { + return { + success: false, + message: `Path exists but is not a directory: ${localPath}`, + }; + } + + if (existingEntries.length === 0) { + const cloneIntoEmptyDir = await gitExec(pi, localPath, "clone", repoUrl, "."); + if (cloneIntoEmptyDir.success) { + isRepoInitialized.value = true; + const repoName = getRepoName(settings); + return { + success: true, + message: `Cloned [${repoName}] successfully`, + updated: true, + }; + } + return { + success: false, + message: "Clone failed - check repo URL and auth", + }; + } + return { success: false, message: `Directory exists but is not a git repo: ${localPath}`, @@ -322,7 +437,7 @@ export function writeMemoryFile( * Build memory context for agent prompt. */ -function ensureDirectoryStructure(memoryDir: string): void { +export function ensureDirectoryStructure(memoryDir: string): void { const dirs = [ path.join(memoryDir, "core", "user"), path.join(memoryDir, "core", "project"), @@ -334,7 +449,7 @@ function ensureDirectoryStructure(memoryDir: string): void { } } -function createDefaultFiles(memoryDir: string): void { +export function createDefaultFiles(memoryDir: string): void { const identityFile = path.join(memoryDir, "core", "user", "identity.md"); if (!fs.existsSync(identityFile)) { writeMemoryFile( @@ -434,7 +549,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) { let memoryInjected = false; pi.on("session_start", async (_event, ctx) => { - settings = loadSettings(); + settings = loadSettings(ctx.cwd); if (!settings.enabled) { return; @@ -451,7 +566,11 @@ export default function memoryMdExtension(pi: ExtensionAPI) { return; } - if (settings.autoSync?.onSessionStart && settings.localPath) { + if ( + settings.autoSync?.onSessionStart && + settings.localPath && + settings.repoUrl + ) { syncPromise = syncRepository(pi, settings, repoInitialized).then( (syncResult) => { if (settings.repoUrl) { @@ -509,14 +628,20 @@ export default function memoryMdExtension(pi: ExtensionAPI) { return undefined; }); - registerAllTools(pi, settings, repoInitialized); + registerAllTools(pi, () => settings, repoInitialized); pi.registerCommand("memory-status", { description: "Show memory repository status", handler: async (_args, ctx) => { + settings = loadSettings(ctx.cwd); const projectName = path.basename(ctx.cwd); const memoryDir = getMemoryDir(settings, ctx); + const projectRepoPath = getProjectRepoPath(settings, ctx); const coreUserDir = path.join(memoryDir, "core", "user"); + const repoConfigured = Boolean(settings.repoUrl); + const repoReady = Boolean( + settings.localPath && fs.existsSync(path.join(settings.localPath, ".git")), + ); if (!fs.existsSync(coreUserDir)) { ctx.ui.notify( @@ -526,12 +651,37 @@ export default function memoryMdExtension(pi: ExtensionAPI) { return; } + if (!repoConfigured) { + ctx.ui.notify( + `Memory: ${projectName} | Local only | Path: ${memoryDir}`, + "info", + ); + return; + } + + if (!repoReady || !settings.localPath) { + ctx.ui.notify( + `Memory: ${projectName} | Repo not initialized | Path: ${memoryDir}`, + "warning", + ); + return; + } + const result = await gitExec( pi, - settings.localPath!, + settings.localPath, "status", "--porcelain", + "--", + projectRepoPath, ); + if (!result.success) { + ctx.ui.notify( + `Memory: ${projectName} | Repo status unavailable | Path: ${memoryDir}`, + "warning", + ); + return; + } const isDirty = result.stdout.trim().length > 0; ctx.ui.notify( @@ -544,26 +694,36 @@ export default function memoryMdExtension(pi: ExtensionAPI) { pi.registerCommand("memory-init", { description: "Initialize memory repository", handler: async (_args, ctx) => { + settings = loadSettings(ctx.cwd); const memoryDir = getMemoryDir(settings, ctx); const alreadyInitialized = fs.existsSync( path.join(memoryDir, "core", "user"), ); - const result = await syncRepository(pi, settings, repoInitialized); - - if (!result.success) { - ctx.ui.notify(`Initialization failed: ${result.message}`, "error"); - return; + if (settings.repoUrl) { + const result = await syncRepository(pi, settings, repoInitialized); + if (!result.success) { + ctx.ui.notify(`Initialization failed: ${result.message}`, "error"); + return; + } } ensureDirectoryStructure(memoryDir); createDefaultFiles(memoryDir); + repoInitialized.value = true; if (alreadyInitialized) { - ctx.ui.notify(`Memory already exists: ${result.message}`, "info"); + ctx.ui.notify( + settings.repoUrl + ? "Memory already exists and repository is ready" + : "Local memory already exists", + "info", + ); } else { ctx.ui.notify( - `Memory initialized: ${result.message}\n\nCreated:\n - core/user\n - core/project\n - reference`, + settings.repoUrl + ? "Memory initialized and repository is ready\n\nCreated:\n - core/user\n - core/project\n - reference" + : "Local memory initialized\n\nCreated:\n - core/user\n - core/project\n - reference", "info", ); } @@ -573,6 +733,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) { pi.registerCommand("memory-refresh", { description: "Refresh memory context from files", handler: async (_args, ctx) => { + settings = loadSettings(ctx.cwd); const memoryContext = buildMemoryContext(settings, ctx); if (!memoryContext) { @@ -610,6 +771,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) { pi.registerCommand("memory-check", { description: "Check memory folder structure", handler: async (_args, ctx) => { + settings = loadSettings(ctx.cwd); const memoryDir = getMemoryDir(settings, ctx); if (!fs.existsSync(memoryDir)) { diff --git a/packages/pi-memory-md/tools.ts b/packages/pi-memory-md/tools.ts index a4e3425..58feb2c 100644 --- a/packages/pi-memory-md/tools.ts +++ b/packages/pi-memory-md/tools.ts @@ -6,15 +6,21 @@ import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js"; import { + createDefaultFiles, + ensureDirectoryStructure, getCurrentDate, getMemoryDir, + getProjectRepoPath, gitExec, listMemoryFiles, readMemoryFile, + resolveMemoryPath, syncRepository, writeMemoryFile, } from "./memory-md.js"; +type MemorySettingsGetter = () => MemoryMdSettings; + function renderWithExpandHint( text: string, theme: Theme, @@ -34,7 +40,7 @@ function renderWithExpandHint( export function registerMemorySync( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, isRepoInitialized: { value: boolean }, ): void { pi.registerTool({ @@ -52,26 +58,73 @@ export function registerMemorySync( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { action } = params as { action: "pull" | "push" | "status" }; - const localPath = settings.localPath!; + const settings = getSettings(); + const localPath = settings.localPath; const memoryDir = getMemoryDir(settings, ctx); + const projectRepoPath = getProjectRepoPath(settings, ctx); const coreUserDir = path.join(memoryDir, "core", "user"); + const configured = Boolean(settings.repoUrl); + const initialized = fs.existsSync(coreUserDir); + const repoReady = Boolean( + localPath && fs.existsSync(path.join(localPath, ".git")), + ); if (action === "status") { - const initialized = - isRepoInitialized.value && fs.existsSync(coreUserDir); if (!initialized) { return { content: [ { type: "text", - text: "Memory repository not initialized. Use memory_init to set up.", + text: "Memory not initialized. Use memory_init to set up.", }, ], - details: { initialized: false }, + details: { initialized: false, configured, dirty: null }, }; } - const result = await gitExec(pi, localPath, "status", "--porcelain"); + if (!configured) { + return { + content: [ + { + type: "text", + text: "Memory repository is not configured. Local memory is available only on this machine.", + }, + ], + details: { initialized: true, configured: false, dirty: null }, + }; + } + + if (!repoReady || !localPath) { + return { + content: [ + { + type: "text", + text: "Memory repository is configured but not initialized locally.", + }, + ], + details: { initialized: true, configured: true, dirty: null }, + }; + } + + const result = await gitExec( + pi, + localPath, + "status", + "--porcelain", + "--", + projectRepoPath, + ); + if (!result.success) { + return { + content: [ + { + type: "text", + text: "Unable to inspect memory repository status.", + }, + ], + details: { initialized: true, configured: true, dirty: null }, + }; + } const dirty = result.stdout.trim().length > 0; return { @@ -88,36 +141,87 @@ export function registerMemorySync( } if (action === "pull") { + if (!configured) { + return { + content: [ + { + type: "text", + text: "Memory repository is not configured. Nothing to pull.", + }, + ], + details: { success: false, configured: false }, + }; + } const result = await syncRepository(pi, settings, isRepoInitialized); return { content: [{ type: "text", text: result.message }], - details: { success: result.success }, + details: { success: result.success, configured: true }, }; } if (action === "push") { + if (!configured || !localPath) { + return { + content: [ + { + type: "text", + text: "Memory repository is not configured. Nothing to push.", + }, + ], + details: { success: false, configured: false }, + }; + } + + if (!repoReady) { + return { + content: [ + { + type: "text", + text: "Memory repository is configured but not initialized locally.", + }, + ], + details: { success: false, configured: true }, + }; + } + const statusResult = await gitExec( pi, localPath, "status", "--porcelain", + "--", + projectRepoPath, ); + if (!statusResult.success) { + return { + content: [ + { + type: "text", + text: "Unable to inspect memory repository before push.", + }, + ], + details: { success: false, configured: true }, + }; + } const hasChanges = statusResult.stdout.trim().length > 0; if (hasChanges) { - await gitExec(pi, localPath, "add", "."); + await gitExec(pi, localPath, "add", "-A", "--", projectRepoPath); const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .slice(0, 19); - const commitMessage = `Update memory - ${timestamp}`; + const commitMessage = `Update memory for ${path.basename(ctx.cwd)} - ${timestamp}`; const commitResult = await gitExec( pi, localPath, "commit", "-m", commitMessage, + "--only", + "--", + projectRepoPath, ); if (!commitResult.success) { @@ -189,7 +293,7 @@ export function registerMemorySync( export function registerMemoryRead( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, ): void { pi.registerTool({ name: "memory_read", @@ -204,8 +308,8 @@ export function registerMemoryRead( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { path: relPath } = params as { path: string }; - const memoryDir = getMemoryDir(settings, ctx); - const fullPath = path.join(memoryDir, relPath); + const settings = getSettings(); + const fullPath = resolveMemoryPath(settings, ctx, relPath); const memory = readMemoryFile(fullPath); if (!memory) { @@ -271,7 +375,7 @@ export function registerMemoryRead( export function registerMemoryWrite( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, ): void { pi.registerTool({ name: "memory_write", @@ -300,17 +404,24 @@ export function registerMemoryWrite( tags?: string[]; }; - const memoryDir = getMemoryDir(settings, ctx); - const fullPath = path.join(memoryDir, relPath); + const settings = getSettings(); + const fullPath = resolveMemoryPath(settings, ctx, relPath); const existing = readMemoryFile(fullPath); - const existingFrontmatter = existing?.frontmatter || { description }; + const existingFrontmatter = existing?.frontmatter; const frontmatter: MemoryFrontmatter = { - ...existingFrontmatter, description, + created: existingFrontmatter?.created ?? getCurrentDate(), updated: getCurrentDate(), - ...(tags && { tags }), + ...(existingFrontmatter?.limit !== undefined + ? { limit: existingFrontmatter.limit } + : {}), + ...(tags !== undefined + ? { tags } + : existingFrontmatter?.tags + ? { tags: existingFrontmatter.tags } + : {}), }; writeMemoryFile(fullPath, content, frontmatter); @@ -367,7 +478,7 @@ export function registerMemoryWrite( export function registerMemoryList( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, ): void { pi.registerTool({ name: "memory_list", @@ -381,8 +492,11 @@ export function registerMemoryList( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { directory } = params as { directory?: string }; + const settings = getSettings(); const memoryDir = getMemoryDir(settings, ctx); - const searchDir = directory ? path.join(memoryDir, directory) : memoryDir; + const searchDir = directory + ? resolveMemoryPath(settings, ctx, directory) + : memoryDir; const files = listMemoryFiles(searchDir); const relPaths = files.map((f) => path.relative(memoryDir, f)); @@ -432,7 +546,7 @@ export function registerMemoryList( export function registerMemorySearch( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, ): void { pi.registerTool({ name: "memory_search", @@ -457,6 +571,7 @@ export function registerMemorySearch( query: string; searchIn: "content" | "tags" | "description"; }; + const settings = getSettings(); const memoryDir = getMemoryDir(settings, ctx); const files = listMemoryFiles(memoryDir); const results: Array<{ path: string; match: string }> = []; @@ -544,7 +659,7 @@ export function registerMemorySearch( export function registerMemoryInit( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, isRepoInitialized: { value: boolean }, ): void { pi.registerTool({ @@ -558,10 +673,19 @@ export function registerMemoryInit( ), }) as any, - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { force = false } = params as { force?: boolean }; + const settings = getSettings(); + const memoryDir = getMemoryDir(settings, ctx); + const alreadyInitialized = fs.existsSync( + path.join(memoryDir, "core", "user"), + ); + const repoReady = Boolean( + settings.localPath && + fs.existsSync(path.join(settings.localPath, ".git")), + ); - if (isRepoInitialized.value && !force) { + if (alreadyInitialized && (!settings.repoUrl || repoReady) && !force) { return { content: [ { @@ -573,18 +697,35 @@ export function registerMemoryInit( }; } - const result = await syncRepository(pi, settings, isRepoInitialized); + if (settings.repoUrl) { + const result = await syncRepository(pi, settings, isRepoInitialized); + if (!result.success) { + return { + content: [ + { + type: "text", + text: `Initialization failed: ${result.message}`, + }, + ], + details: { success: false }, + }; + } + } + + ensureDirectoryStructure(memoryDir); + createDefaultFiles(memoryDir); + isRepoInitialized.value = true; return { content: [ { type: "text", - text: result.success - ? `Memory repository initialized:\n${result.message}\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}` - : `Initialization failed: ${result.message}`, + text: settings.repoUrl + ? `Memory repository initialized.\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}` + : `Local memory initialized.\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`, }, ], - details: { success: result.success }, + details: { success: true }, }; }, @@ -628,7 +769,7 @@ export function registerMemoryInit( export function registerMemoryCheck( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, ): void { pi.registerTool({ name: "memory_check", @@ -637,6 +778,7 @@ export function registerMemoryCheck( parameters: Type.Object({}) as any, async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const settings = getSettings(); const memoryDir = getMemoryDir(settings, ctx); if (!fs.existsSync(memoryDir)) { @@ -719,14 +861,14 @@ export function registerMemoryCheck( export function registerAllTools( pi: ExtensionAPI, - settings: MemoryMdSettings, + getSettings: MemorySettingsGetter, isRepoInitialized: { value: boolean }, ): void { - registerMemorySync(pi, settings, isRepoInitialized); - registerMemoryRead(pi, settings); - registerMemoryWrite(pi, settings); - registerMemoryList(pi, settings); - registerMemorySearch(pi, settings); - registerMemoryInit(pi, settings, isRepoInitialized); - registerMemoryCheck(pi, settings); + registerMemorySync(pi, getSettings, isRepoInitialized); + registerMemoryRead(pi, getSettings); + registerMemoryWrite(pi, getSettings); + registerMemoryList(pi, getSettings); + registerMemorySearch(pi, getSettings); + registerMemoryInit(pi, getSettings, isRepoInitialized); + registerMemoryCheck(pi, getSettings); }