diff --git a/README.md b/README.md index cf83f24..5e11bb6 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This installer: Preinstalled package sources are: ```json -["npm:@e9n/pi-channels", "npm:pi-memory-md", "npm:pi-teams"] +["npm:@e9n/pi-channels", "npm:pi-teams"] ``` If `npm` is available, it also installs these packages during install. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c4f6282..d08633d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -61,6 +61,15 @@ import { type ToolHtmlRenderer, } from "./export-html/index.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; +import { + RuntimeMemoryManager, + type RuntimeMemoryForgetInput, + type RuntimeMemoryRebuildResult, + type RuntimeMemoryRememberInput, + type RuntimeMemorySearchResult, + type RuntimeMemoryStatus, + type RuntimeMemoryRecord, +} from "./memory/runtime-memory.js"; import { type ContextUsage, type ExtensionCommandContextActions, @@ -337,6 +346,8 @@ export class AgentSession { // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt = ""; + private _memoryManager: RuntimeMemoryManager; + private _memoryWriteQueue: Promise = Promise.resolve(); constructor(config: AgentSessionConfig) { this.agent = config.agent; @@ -350,6 +361,10 @@ export class AgentSession { this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._baseToolsOverride = config.baseToolsOverride; + this._memoryManager = new RuntimeMemoryManager({ + sessionManager: this.sessionManager, + settingsManager: this.settingsManager, + }); // Always subscribe to agent events for internal handling // (session persistence, extensions, auto-compaction, retry logic) @@ -499,6 +514,16 @@ export class AgentSession { this._resolveRetry(); } } + + if (event.message.role === "user" || event.message.role === "assistant") { + try { + this._memoryManager.recordMessage(event.message); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error(`[memory] episode write failed: ${message}`); + } + } } // Check auto-retry and auto-compaction after agent completes @@ -513,6 +538,10 @@ export class AgentSession { } await this._checkCompaction(msg); + + if (msg.stopReason !== "error") { + this._enqueueMemoryPromotion(event.messages); + } } } @@ -667,6 +696,9 @@ export class AgentSession { dispose(): void { this._disconnectFromAgent(); this._eventListeners = []; + void this._memoryWriteQueue.finally(() => { + this._memoryManager.dispose(); + }); } // ========================================================================= @@ -804,6 +836,82 @@ export class AgentSession { return this._resourceLoader.getPrompts().prompts; } + async transformRuntimeContext( + messages: AgentMessage[], + signal?: AbortSignal, + ): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.injectContext(messages, { signal }); + } + + async getMemoryStatus(): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.getStatus(); + } + + async getCoreMemories(): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.listCoreMemories(); + } + + async searchMemory( + query: string, + limit?: number, + ): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.search(query, limit); + } + + async rememberMemory( + input: RuntimeMemoryRememberInput, + ): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.remember(input); + } + + async forgetMemory( + input: RuntimeMemoryForgetInput, + ): Promise<{ ok: true; forgotten: boolean }> { + await this._awaitMemoryWrites(); + return this._memoryManager.forget(input); + } + + async rebuildMemory(): Promise { + await this._awaitMemoryWrites(); + return this._memoryManager.rebuild(); + } + + private async _awaitMemoryWrites(): Promise { + try { + await this._memoryWriteQueue; + } catch { + // Memory writes are best-effort; failures should not block chat. + } + } + + private _enqueueMemoryPromotion(messages: AgentMessage[]): void { + this._memoryWriteQueue = this._memoryWriteQueue + .catch(() => undefined) + .then(async () => { + if (!this.model) { + return; + } + const apiKey = await this._modelRegistry.getApiKey(this.model); + if (!apiKey) { + return; + } + await this._memoryManager.promoteTurn({ + model: this.model, + apiKey, + messages, + }); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[memory] promotion failed: ${message}`); + }); + } + private _normalizePromptSnippet( text: string | undefined, ): string | undefined { diff --git a/packages/coding-agent/src/core/gateway/memory.ts b/packages/coding-agent/src/core/gateway/memory.ts deleted file mode 100644 index 6450430..0000000 --- a/packages/coding-agent/src/core/gateway/memory.ts +++ /dev/null @@ -1,963 +0,0 @@ -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 getGitHead(cwd: string): Promise { - const result = await runGit(cwd, "rev-parse", "HEAD"); - if (!result.success) { - return null; - } - const head = result.stdout.trim(); - return head.length > 0 ? head : null; -} - -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 previousHead = await getGitHead(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 currentHead = await getGitHead(localPath); - const updated = - previousHead !== null && - currentHead !== null && - previousHead !== currentHead; - const message = - previousHead === null || currentHead === null - ? `Synchronized ${getRepoName(settings)}` - : updated - ? `Pulled latest changes from ${getRepoName(settings)}` - : `${getRepoName(settings)} is already up to date`; - return { - success: true, - message, - 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 && dirty !== null, - message: repositoryReady - ? dirty === null - ? "Memory repository status is unavailable" - : 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, - }; - } - - const syncResult = await syncRepository(settings); - if (!syncResult.success) { - return { - success: false, - message: syncResult.message, - configured, - initialized, - dirty: null, - updated: syncResult.updated, - }; - } - - 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, - updated: syncResult.updated, - }; -} diff --git a/packages/coding-agent/src/core/gateway/runtime.ts b/packages/coding-agent/src/core/gateway/runtime.ts index 91ba6f7..2a7d007 100644 --- a/packages/coding-agent/src/core/gateway/runtime.ts +++ b/packages/coding-agent/src/core/gateway/runtime.ts @@ -29,15 +29,6 @@ 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, @@ -306,6 +297,16 @@ export class GatewayRuntime { return managedSession; } + private async resolveMemorySession( + sessionKey: string | null | undefined, + ): Promise { + if (!sessionKey || sessionKey === this.primarySessionKey) { + return this.primarySession; + } + const managedSession = await this.ensureSession(sessionKey); + return managedSession.session; + } + private async processNext( managedSession: ManagedGatewaySession, ): Promise { @@ -634,106 +635,89 @@ export class GatewayRuntime { } if (method === "GET" && path === "/memory/status") { - const memory = await getMemoryStatus( - this.primarySession.settingsManager, - this.primarySession.sessionManager.getCwd(), - ); + const sessionKey = url.searchParams.get("sessionKey"); + const memorySession = await this.resolveMemorySession(sessionKey); + const memory = await memorySession.getMemoryStatus(); 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); + if (method === "GET" && path === "/memory/core") { + const sessionKey = url.searchParams.get("sessionKey"); + const memorySession = await this.resolveMemorySession(sessionKey); + const memories = await memorySession.getCoreMemories(); + this.writeJson(response, 200, { memories }); 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, - ); + const limit = + typeof body.limit === "number" && Number.isFinite(body.limit) + ? Math.max(1, Math.floor(body.limit)) + : undefined; + const sessionKey = + typeof body.sessionKey === "string" ? body.sessionKey : undefined; + const memorySession = await this.resolveMemorySession(sessionKey); + const result = await memorySession.searchMemory(query, limit); this.writeJson(response, 200, result); return; } - if (method === "POST" && path === "/memory/sync") { + if (method === "POST" && path === "/memory/remember") { 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, - ); + const content = typeof body.content === "string" ? body.content : ""; + if (!content.trim()) { + this.writeJson(response, 400, { error: "Missing memory content" }); + return; + } + const sessionKey = + typeof body.sessionKey === "string" ? body.sessionKey : undefined; + const memorySession = await this.resolveMemorySession(sessionKey); + const memory = await memorySession.rememberMemory({ + bucket: + body.bucket === "core" || body.bucket === "archival" + ? body.bucket + : undefined, + kind: + body.kind === "profile" || + body.kind === "preference" || + body.kind === "relationship" || + body.kind === "fact" || + body.kind === "secret" + ? body.kind + : undefined, + key: typeof body.key === "string" ? body.key : undefined, + content, + source: "manual", + }); + this.writeJson(response, 200, { ok: true, memory }); + return; + } + + if (method === "POST" && path === "/memory/forget") { + const body = await this.readJsonBody(request); + const sessionKey = + typeof body.sessionKey === "string" ? body.sessionKey : undefined; + const memorySession = await this.resolveMemorySession(sessionKey); + const result = await memorySession.forgetMemory({ + id: + typeof body.id === "number" && Number.isFinite(body.id) + ? Math.floor(body.id) + : undefined, + key: typeof body.key === "string" ? body.key : undefined, + }); + this.writeJson(response, 200, result); + return; + } + + if (method === "POST" && path === "/memory/rebuild") { + const body = await this.readJsonBody(request); + const sessionKey = + typeof body.sessionKey === "string" ? body.sessionKey : undefined; + const memorySession = await this.resolveMemorySession(sessionKey); + const result = await memorySession.rebuildMemory(); this.writeJson(response, 200, result); return; } diff --git a/packages/coding-agent/src/core/memory/runtime-memory.ts b/packages/coding-agent/src/core/memory/runtime-memory.ts new file mode 100644 index 0000000..ed8454a --- /dev/null +++ b/packages/coding-agent/src/core/memory/runtime-memory.ts @@ -0,0 +1,1532 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { homedir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { + completeSimple, + type Model, + type TextContent, +} from "@mariozechner/pi-ai"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import type { SettingsManager } from "../settings-manager.js"; +import type { ReadonlySessionManager } from "../session-manager.js"; + +const DEFAULT_STORAGE_DIR = join(homedir(), ".pi", "memory"); +const MAX_EPISODE_CHARS = 4_000; +const MAX_EPISODES = 5_000; +const DEFAULT_CORE_TOKEN_BUDGET = 700; +const DEFAULT_RECALL_RESULTS = 4; +const DEFAULT_WRITER_MAX_TOKENS = 600; +const CUSTOM_MEMORY_TYPE = "companion_memory"; + +const MEMORY_WRITER_SYSTEM_PROMPT = `You manage long-term conversational memory for a companion agent. + +Decide which facts from the latest exchange should be persisted for future chats. + +Rules: +- Save only information grounded in the user or assistant messages. +- Prefer durable facts, explicit remember requests, stable preferences, relationship context, and secrets/keys/codes the user will expect the companion to recall later. +- Use bucket "core" only for stable profile, preference, or relationship memory. +- Use bucket "archival" for facts and secrets that should be searchable later. +- Never invent details or infer beyond the exchange. +- If nothing should be saved, return {"memories":[]}. + +Return strict JSON with this shape: +{"memories":[{"bucket":"core"|"archival","kind":"profile"|"preference"|"relationship"|"fact"|"secret","key":"stable-memory-slot","content":"memory text"}]}`; + +export type RuntimeMemoryBucket = "core" | "archival"; +export type RuntimeMemoryKind = + | "profile" + | "preference" + | "relationship" + | "fact" + | "secret"; +export type RuntimeMemorySource = "auto" | "manual" | "legacy-import"; + +export interface CompanionMemorySettings { + enabled?: boolean; + storageDir?: string; + maxCoreTokens?: number; + maxRecallResults?: number; + writer?: { + enabled?: boolean; + maxTokens?: number; + }; +} + +export interface RuntimeMemoryIdentity { + key: string; + scope: "agent" | "companion" | "sandbox" | "unknown"; +} + +export interface RuntimeMemoryStatus { + enabled: boolean; + ready: boolean; + identity: RuntimeMemoryIdentity | null; + storagePath: string | null; + coreCount: number; + archivalCount: number; + episodeCount: number; + lastMemoryWriteAt: number | null; + lastEpisodeAt: number | null; + legacyImportComplete: boolean; +} + +export interface RuntimeMemoryRecord { + id: number; + bucket: RuntimeMemoryBucket; + kind: RuntimeMemoryKind; + key: string; + content: string; + source: RuntimeMemorySource; + createdAt: number; + updatedAt: number; + lastAccessedAt: number | null; +} + +export interface RuntimeMemoryRememberInput { + bucket?: RuntimeMemoryBucket; + kind?: RuntimeMemoryKind; + key?: string; + content: string; + source?: RuntimeMemorySource; +} + +export interface RuntimeMemoryForgetInput { + id?: number; + key?: string; +} + +export interface RuntimeMemorySearchResultItem { + id: number; + sourceType: "memory" | "episode"; + score: number; + kind?: RuntimeMemoryKind; + bucket?: RuntimeMemoryBucket; + key?: string; + content: string; + role?: "user" | "assistant"; + timestamp: number; + source?: RuntimeMemorySource; +} + +export interface RuntimeMemorySearchResult { + query: string; + results: RuntimeMemorySearchResultItem[]; +} + +export interface RuntimeMemoryRebuildResult { + ok: true; + memoryRows: number; + episodeRows: number; +} + +interface MemoryRow { + id: number; + bucket: RuntimeMemoryBucket; + kind: RuntimeMemoryKind; + memory_key: string; + content: string; + source: RuntimeMemorySource; + created_at: number; + updated_at: number; + last_accessed_at: number | null; + search_text: string; +} + +interface EpisodeRow { + id: number; + role: "user" | "assistant"; + text: string; + timestamp: number; + search_text: string; +} + +interface MemoryWriterResponse { + memories?: Array<{ + bucket?: unknown; + kind?: unknown; + key?: unknown; + content?: unknown; + }>; +} + +interface LegacyMemoryFile { + path: string; + body: string; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function expandHomePath(value: string): string { + if (!value.startsWith("~")) { + return value; + } + return join(homedir(), value.slice(1)); +} + +function getCompanionMemorySettings( + settingsManager: SettingsManager, +): Required & { + writer: { enabled: boolean; maxTokens: number }; +} { + const globalSettings = asRecord(settingsManager.getGlobalSettings()) ?? {}; + const projectSettings = asRecord(settingsManager.getProjectSettings()) ?? {}; + const globalMemory = asRecord(globalSettings.companionMemory) ?? {}; + const projectMemory = asRecord(projectSettings.companionMemory) ?? {}; + + const enabled = + typeof projectMemory.enabled === "boolean" + ? projectMemory.enabled + : typeof globalMemory.enabled === "boolean" + ? globalMemory.enabled + : true; + const storageDir = + asString(projectMemory.storageDir) ?? + asString(globalMemory.storageDir) ?? + DEFAULT_STORAGE_DIR; + const maxCoreTokens = + (typeof projectMemory.maxCoreTokens === "number" + ? projectMemory.maxCoreTokens + : typeof globalMemory.maxCoreTokens === "number" + ? globalMemory.maxCoreTokens + : DEFAULT_CORE_TOKEN_BUDGET) || DEFAULT_CORE_TOKEN_BUDGET; + const maxRecallResults = + (typeof projectMemory.maxRecallResults === "number" + ? projectMemory.maxRecallResults + : typeof globalMemory.maxRecallResults === "number" + ? globalMemory.maxRecallResults + : DEFAULT_RECALL_RESULTS) || DEFAULT_RECALL_RESULTS; + + const globalWriter = asRecord(globalMemory.writer) ?? {}; + const projectWriter = asRecord(projectMemory.writer) ?? {}; + const writerEnabled = + typeof projectWriter.enabled === "boolean" + ? projectWriter.enabled + : typeof globalWriter.enabled === "boolean" + ? globalWriter.enabled + : true; + const writerMaxTokens = + (typeof projectWriter.maxTokens === "number" + ? projectWriter.maxTokens + : typeof globalWriter.maxTokens === "number" + ? globalWriter.maxTokens + : DEFAULT_WRITER_MAX_TOKENS) || DEFAULT_WRITER_MAX_TOKENS; + + return { + enabled, + storageDir: expandHomePath(storageDir), + maxCoreTokens, + maxRecallResults, + writer: { + enabled: writerEnabled, + maxTokens: writerMaxTokens, + }, + }; +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function normalizeSearchText(value: string): string { + return normalizeWhitespace( + value + .toLowerCase() + .replace(/[`"'()[\]{}<>]/g, " ") + .replace(/[^a-z0-9._:/+-]+/g, " "), + ); +} + +function tokenize(value: string): string[] { + const seen = new Set(); + for (const token of normalizeSearchText(value).split(" ")) { + if (token.length < 2) { + continue; + } + seen.add(token); + } + return Array.from(seen); +} + +function estimateTextTokens(value: string): number { + return Math.max(1, Math.ceil(value.length / 4)); +} + +function buildDbFileName(identity: RuntimeMemoryIdentity): string { + const slug = identity.key.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80); + const hash = createHash("sha256") + .update(identity.key) + .digest("hex") + .slice(0, 12); + return `${slug}-${hash}.sqlite`; +} + +function parseAgentIdFromSessionKey(value: string): string | null { + const match = value.match(/^agent:([^:]+):companion:[^:]+$/); + return match?.[1] ?? null; +} + +function parseAgentIdFromSanitizedSessionKey(value: string): string | null { + if (!value.startsWith("agent_")) { + return null; + } + const marker = "_companion_"; + const markerIndex = value.lastIndexOf(marker); + if (markerIndex <= "agent_".length) { + return null; + } + return value.slice("agent_".length, markerIndex); +} + +function resolveIdentity(params: { + sessionManager: ReadonlySessionManager; + settingsManager: SettingsManager; +}): RuntimeMemoryIdentity | null { + const settings = asRecord(params.settingsManager.getGlobalSettings()) ?? {}; + const sessionDirName = basename(params.sessionManager.getSessionDir()); + const sessionAgentId = parseAgentIdFromSanitizedSessionKey(sessionDirName); + if (sessionAgentId) { + return { key: `agent:${sessionAgentId}`, scope: "agent" }; + } + + const directSessionKey = asString(settings.sessionKey); + const directAgentId = directSessionKey + ? parseAgentIdFromSessionKey(directSessionKey) + : null; + if (directAgentId) { + return { key: `agent:${directAgentId}`, scope: "agent" }; + } + + const companion = asRecord(settings.companion); + const explicitCompanionId = asString(companion?.id); + if (explicitCompanionId) { + return { key: `companion:${explicitCompanionId}`, scope: "companion" }; + } + + const sandboxHandle = asString(settings.sandboxHandle); + if (sandboxHandle) { + return { key: `sandbox:${sandboxHandle}`, scope: "sandbox" }; + } + + return null; +} + +function extractTextFromMessage(message: AgentMessage): string { + if (message.role !== "user" && message.role !== "assistant") { + return ""; + } + + if (typeof message.content === "string") { + return normalizeWhitespace(message.content); + } + + if (!Array.isArray(message.content)) { + return ""; + } + + return normalizeWhitespace( + message.content + .filter((part): part is TextContent => part.type === "text") + .map((part) => part.text) + .join("\n"), + ); +} + +function createSearchText(memory: { + bucket: RuntimeMemoryBucket; + kind: RuntimeMemoryKind; + key: string; + content: string; +}): string { + return normalizeSearchText( + `${memory.bucket} ${memory.kind} ${memory.key} ${memory.content}`, + ); +} + +function createEpisodeSearchText( + role: "user" | "assistant", + text: string, +): string { + return normalizeSearchText(`${role} ${text}`); +} + +function clampText(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + return `${value.slice(0, maxChars - 3)}...`; +} + +function trimSnippet(value: string, maxChars = 220): string { + const trimmed = normalizeWhitespace(value); + if (trimmed.length <= maxChars) { + return trimmed; + } + return `${trimmed.slice(0, maxChars - 3)}...`; +} + +function isMemoryBucket(value: unknown): value is RuntimeMemoryBucket { + return value === "core" || value === "archival"; +} + +function isMemoryKind(value: unknown): value is RuntimeMemoryKind { + return ( + value === "profile" || + value === "preference" || + value === "relationship" || + value === "fact" || + value === "secret" + ); +} + +function defaultBucketForKind(kind: RuntimeMemoryKind): RuntimeMemoryBucket { + switch (kind) { + case "profile": + case "preference": + case "relationship": + return "core"; + case "fact": + case "secret": + return "archival"; + } +} + +function normalizeMemoryKey(value: string): string { + const normalized = normalizeSearchText(value).replace(/\s+/g, "-"); + return normalized.length > 0 ? normalized : "memory"; +} + +function unwrapJson(text: string): string { + const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedMatch?.[1]) { + return fencedMatch[1].trim(); + } + return text.trim(); +} + +function scoreCandidate( + searchText: string, + queryTokens: string[], + rawText: string, + timestamp: number, + boost = 0, +): number { + if (queryTokens.length === 0) { + return 1; + } + + let score = boost; + let matchedAll = true; + for (const token of queryTokens) { + if (searchText.includes(token)) { + score += token.length >= 5 ? 16 : 10; + } else { + matchedAll = false; + } + } + + const normalizedRaw = normalizeSearchText(rawText); + const phrase = normalizeSearchText(queryTokens.join(" ")); + if (phrase.length > 0 && normalizedRaw.includes(phrase)) { + score += 24; + } + if (matchedAll) { + score += 14; + } + + const ageDays = Math.max(0, (Date.now() - timestamp) / 86_400_000); + score += Math.max(0, 8 - ageDays / 14); + + return score; +} + +function listMarkdownFiles(rootDir: string): string[] { + if (!existsSync(rootDir)) { + return []; + } + + const results: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(fullPath); + } + } + } + + return results.sort((left, right) => left.localeCompare(right)); +} + +function readLegacyMemoryFiles(baseDir: string): LegacyMemoryFile[] { + const files = listMarkdownFiles(join(baseDir, "core", "user")); + return files + .map((filePath) => { + try { + const raw = readFileSync(filePath, "utf8"); + const parsed = parseFrontmatter>(raw); + const body = normalizeWhitespace(parsed.body); + if (!body) { + return null; + } + return { path: filePath, body }; + } catch { + return null; + } + }) + .filter((file): file is LegacyMemoryFile => file !== null); +} + +function guessLegacyKind(filePath: string, body: string): RuntimeMemoryKind { + const lowerPath = filePath.toLowerCase(); + const lowerBody = body.toLowerCase(); + if (lowerPath.includes("prefer") || lowerBody.includes("preferences")) { + return "preference"; + } + if ( + lowerPath.includes("identity") || + lowerBody.includes("about your human") || + lowerBody.includes("user identity") + ) { + return "profile"; + } + return "relationship"; +} + +export class RuntimeMemoryManager { + private readonly sessionManager: ReadonlySessionManager; + private readonly settingsManager: SettingsManager; + private readonly settings: Required & { + writer: { enabled: boolean; maxTokens: number }; + }; + private readonly identity: RuntimeMemoryIdentity | null; + private readonly dbPath: string | null; + private readonly database: DatabaseSync | null; + + constructor(params: { + sessionManager: ReadonlySessionManager; + settingsManager: SettingsManager; + }) { + this.sessionManager = params.sessionManager; + this.settingsManager = params.settingsManager; + this.settings = getCompanionMemorySettings(params.settingsManager); + this.identity = this.settings.enabled ? resolveIdentity(params) : null; + + if (!this.settings.enabled || !this.identity) { + this.dbPath = null; + this.database = null; + return; + } + + mkdirSync(this.settings.storageDir, { recursive: true }); + this.dbPath = join( + this.settings.storageDir, + buildDbFileName(this.identity), + ); + this.database = new DatabaseSync(this.dbPath); + this.database.exec("PRAGMA journal_mode = WAL;"); + this.database.exec("PRAGMA busy_timeout = 5000;"); + this.database.exec("PRAGMA synchronous = NORMAL;"); + this.database.exec(` + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket TEXT NOT NULL, + kind TEXT NOT NULL, + memory_key TEXT NOT NULL, + content TEXT NOT NULL, + search_text TEXT NOT NULL, + source TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_accessed_at INTEGER, + superseded_at INTEGER, + superseded_by_id INTEGER + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_active_key + ON memories(memory_key) + WHERE active = 1; + CREATE INDEX IF NOT EXISTS idx_memories_bucket_active + ON memories(bucket, active, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_memories_updated_at + ON memories(updated_at DESC); + + CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + session_ref TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + search_text TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_episodes_timestamp + ON episodes(timestamp DESC); + + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + + this.runLegacyImport(); + } + + dispose(): void { + this.database?.close(); + } + + isEnabled(): boolean { + return this.database !== null && this.identity !== null; + } + + getStatus(): RuntimeMemoryStatus { + if (!this.database || !this.identity) { + return { + enabled: this.settings.enabled, + ready: false, + identity: null, + storagePath: null, + coreCount: 0, + archivalCount: 0, + episodeCount: 0, + lastMemoryWriteAt: null, + lastEpisodeAt: null, + legacyImportComplete: false, + }; + } + + const counts = this.database + .prepare( + `SELECT + SUM(CASE WHEN bucket = 'core' AND active = 1 THEN 1 ELSE 0 END) AS core_count, + SUM(CASE WHEN bucket = 'archival' AND active = 1 THEN 1 ELSE 0 END) AS archival_count + FROM memories`, + ) + .get() as { core_count?: number | null; archival_count?: number | null }; + const episodeCountRow = this.database + .prepare(`SELECT COUNT(*) AS count FROM episodes`) + .get() as { count: number }; + const lastMemoryWrite = this.database + .prepare(`SELECT MAX(updated_at) AS updated_at FROM memories`) + .get() as { updated_at?: number | null }; + const lastEpisodeWrite = this.database + .prepare(`SELECT MAX(timestamp) AS timestamp FROM episodes`) + .get() as { timestamp?: number | null }; + + return { + enabled: true, + ready: true, + identity: this.identity, + storagePath: this.dbPath, + coreCount: counts.core_count ?? 0, + archivalCount: counts.archival_count ?? 0, + episodeCount: episodeCountRow.count, + lastMemoryWriteAt: lastMemoryWrite.updated_at ?? null, + lastEpisodeAt: lastEpisodeWrite.timestamp ?? null, + legacyImportComplete: + this.getMetadata("legacy_import_complete") === "true", + }; + } + + listCoreMemories(): RuntimeMemoryRecord[] { + if (!this.database) { + return []; + } + + const rows = this.database + .prepare( + `SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories + WHERE active = 1 AND bucket = 'core' + ORDER BY updated_at DESC, id DESC`, + ) + .all() as MemoryRow[]; + return rows.map((row) => this.mapMemoryRow(row)); + } + + search( + query: string, + limit = DEFAULT_RECALL_RESULTS, + ): RuntimeMemorySearchResult { + if (!this.database) { + return { query, results: [] }; + } + + const queryText = normalizeWhitespace(query); + const queryTokens = tokenize(queryText); + const memoryRows = this.findRelevantMemories( + queryTokens, + Math.max(limit * 4, 12), + ); + const episodeRows = this.findRelevantEpisodes( + queryTokens, + Math.max(limit * 4, 20), + ); + const results: RuntimeMemorySearchResultItem[] = []; + + for (const row of memoryRows) { + const boost = row.kind === "secret" ? 6 : row.bucket === "core" ? 3 : 0; + const score = scoreCandidate( + row.search_text, + queryTokens, + `${row.memory_key} ${row.content}`, + row.updated_at, + boost, + ); + if (score <= 0) { + continue; + } + results.push({ + id: row.id, + sourceType: "memory", + score, + kind: row.kind, + bucket: row.bucket, + key: row.memory_key, + content: row.content, + source: row.source, + timestamp: row.updated_at, + }); + } + + for (const row of episodeRows) { + const score = scoreCandidate( + row.search_text, + queryTokens, + row.text, + row.timestamp, + row.role === "assistant" ? 1 : 0, + ); + if (score <= 0) { + continue; + } + results.push({ + id: row.id, + sourceType: "episode", + score, + role: row.role, + content: row.text, + timestamp: row.timestamp, + }); + } + + results.sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return right.timestamp - left.timestamp; + }); + + return { + query: queryText, + results: results.slice(0, limit), + }; + } + + remember(input: RuntimeMemoryRememberInput): RuntimeMemoryRecord | null { + if (!this.database) { + return null; + } + + const content = normalizeWhitespace(input.content); + if (!content) { + return null; + } + + const kind = input.kind ?? "fact"; + const bucket = input.bucket ?? defaultBucketForKind(kind); + const memoryKey = normalizeMemoryKey(input.key ?? content); + const now = Date.now(); + + const existing = this.database + .prepare( + `SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories + WHERE memory_key = ? AND active = 1`, + ) + .get(memoryKey) as MemoryRow | undefined; + + if (existing) { + if ( + existing.content === content && + existing.bucket === bucket && + existing.kind === kind + ) { + this.database + .prepare( + `UPDATE memories + SET updated_at = ?, last_accessed_at = ? + WHERE id = ?`, + ) + .run(now, now, existing.id); + return this.getMemoryById(existing.id); + } + + this.database + .prepare( + `UPDATE memories + SET active = 0, superseded_at = ? + WHERE id = ?`, + ) + .run(now, existing.id); + } + + const insertResult = this.database + .prepare( + `INSERT INTO memories ( + bucket, + kind, + memory_key, + content, + search_text, + source, + active, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + ) + .run( + bucket, + kind, + memoryKey, + content, + createSearchText({ + bucket, + kind, + key: memoryKey, + content, + }), + input.source ?? "manual", + now, + now, + ); + + return this.getMemoryById(Number(insertResult.lastInsertRowid)); + } + + forget(input: RuntimeMemoryForgetInput): { ok: true; forgotten: boolean } { + if (!this.database) { + return { ok: true, forgotten: false }; + } + + if (typeof input.id === "number") { + const result = this.database + .prepare( + `UPDATE memories + SET active = 0, superseded_at = ? + WHERE id = ? AND active = 1`, + ) + .run(Date.now(), input.id); + return { ok: true, forgotten: result.changes > 0 }; + } + + if (input.key) { + const result = this.database + .prepare( + `UPDATE memories + SET active = 0, superseded_at = ? + WHERE memory_key = ? AND active = 1`, + ) + .run(Date.now(), normalizeMemoryKey(input.key)); + return { ok: true, forgotten: result.changes > 0 }; + } + + return { ok: true, forgotten: false }; + } + + rebuild(): RuntimeMemoryRebuildResult { + if (!this.database) { + return { ok: true, memoryRows: 0, episodeRows: 0 }; + } + + const memoryRows = this.database + .prepare( + `SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories`, + ) + .all() as MemoryRow[]; + for (const row of memoryRows) { + this.database + .prepare(`UPDATE memories SET search_text = ? WHERE id = ?`) + .run( + createSearchText({ + bucket: row.bucket, + kind: row.kind, + key: row.memory_key, + content: row.content, + }), + row.id, + ); + } + + const episodeRows = this.database + .prepare(`SELECT id, role, text, timestamp, search_text FROM episodes`) + .all() as EpisodeRow[]; + for (const row of episodeRows) { + this.database + .prepare(`UPDATE episodes SET search_text = ? WHERE id = ?`) + .run(createEpisodeSearchText(row.role, row.text), row.id); + } + + this.database.exec("VACUUM;"); + + return { + ok: true, + memoryRows: memoryRows.length, + episodeRows: episodeRows.length, + }; + } + + recordMessage(message: AgentMessage): void { + if (!this.database) { + return; + } + if (message.role !== "user" && message.role !== "assistant") { + return; + } + + const text = clampText(extractTextFromMessage(message), MAX_EPISODE_CHARS); + if (!text) { + return; + } + + this.database + .prepare( + `INSERT INTO episodes ( + session_id, + session_ref, + role, + text, + search_text, + timestamp + ) VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + this.sessionManager.getSessionId(), + basename(this.sessionManager.getSessionDir()), + message.role, + text, + createEpisodeSearchText(message.role, text), + message.timestamp, + ); + + this.trimEpisodes(); + } + + async injectContext( + messages: AgentMessage[], + options?: { signal?: AbortSignal }, + ): Promise { + if (!this.database) { + return messages; + } + + options?.signal?.throwIfAborted?.(); + const lastUserIndex = findLastUserMessageIndex(messages); + if (lastUserIndex === -1) { + return messages; + } + + const userMessage = messages[lastUserIndex]; + const userText = extractTextFromMessage(userMessage); + if (!userText) { + return messages; + } + + const core = this.selectCoreRecall(); + const search = this.search(userText, this.settings.maxRecallResults); + const memoryIds = search.results + .filter( + ( + item, + ): item is RuntimeMemorySearchResultItem & { sourceType: "memory" } => + item.sourceType === "memory", + ) + .map((item) => item.id); + this.touchMemories(memoryIds); + + const memoryBlock = renderMemoryBlock(core, search.results); + if (!memoryBlock) { + return messages; + } + + const injectedMessage: AgentMessage = { + role: "custom", + customType: CUSTOM_MEMORY_TYPE, + content: memoryBlock, + display: false, + details: { + identity: this.identity, + }, + timestamp: Date.now(), + }; + + return [ + ...messages.slice(0, lastUserIndex + 1), + injectedMessage, + ...messages.slice(lastUserIndex + 1), + ]; + } + + async promoteTurn(params: { + model: Model | undefined; + apiKey: string | undefined; + messages: AgentMessage[]; + signal?: AbortSignal; + }): Promise { + if (!this.database || !this.settings.writer.enabled || !params.model) { + return; + } + + const userText = findLastRoleText(params.messages, "user"); + const assistantText = findLastRoleText(params.messages, "assistant"); + if (!userText || !assistantText) { + return; + } + + const response = await completeSimple( + params.model, + { + systemPrompt: MEMORY_WRITER_SYSTEM_PROMPT, + messages: [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: [ + `Latest user message:`, + `${userText}`, + ``, + `Latest assistant reply:`, + `${assistantText}`, + ].join("\n"), + }, + ], + timestamp: Date.now(), + }, + ], + }, + params.model.reasoning + ? { + apiKey: params.apiKey, + maxTokens: this.settings.writer.maxTokens, + signal: params.signal, + reasoning: "low", + } + : { + apiKey: params.apiKey, + maxTokens: this.settings.writer.maxTokens, + signal: params.signal, + }, + ); + + if (response.stopReason === "error") { + return; + } + + const text = unwrapJson( + response.content + .filter( + (part): part is { type: "text"; text: string } => + part.type === "text", + ) + .map((part) => part.text) + .join("\n"), + ); + + let parsed: MemoryWriterResponse | null = null; + try { + parsed = JSON.parse(text) as MemoryWriterResponse; + } catch { + parsed = null; + } + + const candidates = Array.isArray(parsed?.memories) ? parsed.memories : []; + const remembered = new Set(); + for (const candidate of candidates) { + if (!isMemoryKind(candidate.kind)) { + continue; + } + const kind = candidate.kind; + const bucket = isMemoryBucket(candidate.bucket) + ? candidate.bucket + : defaultBucketForKind(kind); + const content = asString(candidate.content); + if (!content) { + continue; + } + + const key = normalizeMemoryKey( + asString(candidate.key) ?? trimSnippet(content, 80), + ); + const dedupeKey = `${bucket}:${kind}:${key}`; + if (remembered.has(dedupeKey)) { + continue; + } + remembered.add(dedupeKey); + this.remember({ + bucket, + kind, + key, + content, + source: "auto", + }); + } + + if (remembered.size > 0) { + return; + } + + const fallback = inferFallbackMemory(userText); + if (fallback) { + this.remember({ + ...fallback, + source: "auto", + }); + } + } + + private runLegacyImport(): void { + if ( + !this.database || + this.getMetadata("legacy_import_complete") === "true" + ) { + return; + } + + const legacyDir = resolveLegacyProjectDir( + this.settingsManager, + this.sessionManager.getCwd(), + ); + if (!legacyDir) { + this.setMetadata("legacy_import_complete", "true"); + return; + } + + const stats = statSyncSafe(legacyDir); + if (!stats?.isDirectory()) { + this.setMetadata("legacy_import_complete", "true"); + return; + } + + const legacyFiles = readLegacyMemoryFiles(legacyDir); + for (const file of legacyFiles) { + const kind = guessLegacyKind(file.path, file.body); + this.remember({ + bucket: defaultBucketForKind(kind), + kind, + key: normalizeMemoryKey(`legacy:${basename(file.path, ".md")}`), + content: trimSnippet(file.body, 500), + source: "legacy-import", + }); + } + + this.setMetadata("legacy_import_complete", "true"); + } + + private findRelevantMemories( + queryTokens: string[], + limit: number, + ): MemoryRow[] { + if (!this.database) { + return []; + } + + let sql = ` + SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories + WHERE active = 1`; + const values: string[] = []; + if (queryTokens.length > 0) { + sql += ` AND (${queryTokens.map(() => `instr(search_text, ?) > 0`).join(" OR ")})`; + values.push(...queryTokens); + } + sql += ` ORDER BY updated_at DESC, id DESC LIMIT ${limit}`; + + return this.database.prepare(sql).all(...values) as MemoryRow[]; + } + + private findRelevantEpisodes( + queryTokens: string[], + limit: number, + ): EpisodeRow[] { + if (!this.database) { + return []; + } + + let sql = ` + SELECT + id, + role, + text, + timestamp, + search_text + FROM episodes`; + const values: string[] = []; + if (queryTokens.length > 0) { + sql += ` WHERE ${queryTokens.map(() => `instr(search_text, ?) > 0`).join(" OR ")}`; + values.push(...queryTokens); + } + sql += ` ORDER BY timestamp DESC, id DESC LIMIT ${limit}`; + + return this.database.prepare(sql).all(...values) as EpisodeRow[]; + } + + private selectCoreRecall(): RuntimeMemoryRecord[] { + if (!this.database) { + return []; + } + + const rows = this.database + .prepare( + `SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories + WHERE active = 1 AND bucket = 'core' AND kind != 'secret' + ORDER BY updated_at DESC, id DESC`, + ) + .all() as MemoryRow[]; + + const selected: RuntimeMemoryRecord[] = []; + let usedTokens = 0; + for (const row of rows) { + const memory = this.mapMemoryRow(row); + const nextTokens = + estimateTextTokens(memory.content) + estimateTextTokens(memory.key); + if ( + selected.length > 0 && + usedTokens + nextTokens > this.settings.maxCoreTokens + ) { + break; + } + selected.push(memory); + usedTokens += nextTokens; + } + return selected; + } + + private touchMemories(ids: number[]): void { + if (!this.database || ids.length === 0) { + return; + } + const unique = Array.from(new Set(ids)); + const placeholders = unique.map(() => "?").join(", "); + this.database + .prepare( + `UPDATE memories + SET last_accessed_at = ? + WHERE id IN (${placeholders})`, + ) + .run(Date.now(), ...unique); + } + + private trimEpisodes(): void { + if (!this.database) { + return; + } + + const countRow = this.database + .prepare(`SELECT COUNT(*) AS count FROM episodes`) + .get() as { count: number }; + if (countRow.count <= MAX_EPISODES) { + return; + } + + const overflow = countRow.count - MAX_EPISODES; + this.database + .prepare( + `DELETE FROM episodes + WHERE id IN ( + SELECT id FROM episodes + ORDER BY timestamp ASC, id ASC + LIMIT ? + )`, + ) + .run(overflow); + } + + private getMemoryById(id: number): RuntimeMemoryRecord | null { + if (!this.database) { + return null; + } + const row = this.database + .prepare( + `SELECT + id, + bucket, + kind, + memory_key, + content, + source, + created_at, + updated_at, + last_accessed_at, + search_text + FROM memories + WHERE id = ?`, + ) + .get(id) as MemoryRow | undefined; + return row ? this.mapMemoryRow(row) : null; + } + + private getMetadata(key: string): string | null { + if (!this.database) { + return null; + } + const row = this.database + .prepare(`SELECT value FROM metadata WHERE key = ?`) + .get(key) as { value?: string } | undefined; + return row?.value ?? null; + } + + private setMetadata(key: string, value: string): void { + if (!this.database) { + return; + } + this.database + .prepare( + `INSERT INTO metadata (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ) + .run(key, value); + } + + private mapMemoryRow(row: MemoryRow): RuntimeMemoryRecord { + return { + id: row.id, + bucket: row.bucket, + kind: row.kind, + key: row.memory_key, + content: row.content, + source: row.source, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastAccessedAt: row.last_accessed_at, + }; + } +} + +function statSyncSafe(path: string): ReturnType | null { + try { + return statSync(path); + } catch { + return null; + } +} + +function resolveLegacyProjectDir( + settingsManager: SettingsManager, + cwd: string, +): string | null { + const settings = asRecord(settingsManager.getGlobalSettings()) ?? {}; + const legacySettings = asRecord(settings["pi-memory-md"]) ?? {}; + const configuredRoot = + asString(legacySettings.localPath) ?? join(homedir(), ".pi", "memory-md"); + const legacyRoot = expandHomePath(configuredRoot); + const legacyProjectDir = join(legacyRoot, basename(cwd)); + if (existsSync(legacyProjectDir)) { + return legacyProjectDir; + } + + const hashedDir = join( + legacyRoot, + `${basename(cwd)}-${createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12)}`, + ); + return existsSync(hashedDir) ? hashedDir : null; +} + +function renderMemoryBlock( + coreMemories: RuntimeMemoryRecord[], + searchResults: RuntimeMemorySearchResultItem[], +): string | null { + const lines: string[] = []; + const coreIds = new Set(coreMemories.map((memory) => memory.id)); + + if (coreMemories.length > 0) { + lines.push("Companion Memory"); + lines.push(""); + lines.push("Core memory:"); + for (const memory of coreMemories) { + lines.push(`- [${memory.kind}] ${memory.content}`); + } + } + + const memoryResults = searchResults.filter( + (item) => item.sourceType === "memory" && !coreIds.has(item.id), + ); + const episodeResults = searchResults.filter( + (item) => item.sourceType === "episode", + ); + + if (memoryResults.length > 0) { + if (lines.length === 0) { + lines.push("Companion Memory"); + lines.push(""); + } else { + lines.push(""); + } + lines.push("Relevant long-term memory:"); + for (const result of memoryResults) { + lines.push(`- [${result.kind}] ${trimSnippet(result.content)}`); + } + } + + if (episodeResults.length > 0) { + if (lines.length === 0) { + lines.push("Companion Memory"); + lines.push(""); + } else { + lines.push(""); + } + lines.push("Relevant past conversation snippets:"); + for (const result of episodeResults) { + const date = new Date(result.timestamp).toISOString().slice(0, 10); + lines.push(`- (${date}) ${trimSnippet(result.content)}`); + } + } + + if (lines.length === 0) { + return null; + } + + lines.push(""); + lines.push( + "Use this memory when it is relevant. If a memory might be outdated or ambiguous, verify it with the user.", + ); + return lines.join("\n"); +} + +function findLastUserMessageIndex(messages: AgentMessage[]): number { + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index]?.role === "user") { + return index; + } + } + return -1; +} + +function findLastRoleText( + messages: AgentMessage[], + role: "user" | "assistant", +): string { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === role) { + const text = extractTextFromMessage(message); + if (text) { + return text; + } + } + } + return ""; +} + +function inferFallbackMemory( + userText: string, +): Omit | null { + const normalized = userText.toLowerCase(); + if ( + /\b(password|passcode|pin|token|secret|api key|door code|key code|wifi password)\b/i.test( + userText, + ) + ) { + return { + bucket: "archival", + kind: "secret", + key: normalizeMemoryKey(trimSnippet(userText, 80)), + content: trimSnippet(userText, 300), + }; + } + + if (/\bremember\b/i.test(normalized)) { + return { + bucket: "archival", + kind: "fact", + key: normalizeMemoryKey(trimSnippet(userText, 80)), + content: trimSnippet(userText, 300), + }; + } + + return null; +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9773f0a..80625b3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -315,6 +315,7 @@ export async function createAgentSession( }; const extensionRunnerRef: { current?: ExtensionRunner } = {}; + const sessionRef: { current?: AgentSession } = {}; agent = new Agent({ initialState: { @@ -326,9 +327,15 @@ export async function createAgentSession( convertToLlm: convertToLlmWithBlockImages, sessionId: sessionManager.getSessionId(), transformContext: async (messages) => { + const currentSession = sessionRef.current; + let transformedMessages = messages; + if (currentSession) { + transformedMessages = + await currentSession.transformRuntimeContext(transformedMessages); + } const runner = extensionRunnerRef.current; - if (!runner) return messages; - return runner.emitContext(messages); + if (!runner) return transformedMessages; + return runner.emitContext(transformedMessages); }, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), @@ -388,6 +395,7 @@ export async function createAgentSession( initialActiveToolNames, extensionRunnerRef, }); + sessionRef.current = session; const extensionsResult = resourceLoader.getExtensions(); return { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ca54c5e..ee31113 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -63,6 +63,17 @@ export interface GatewaySettings { webhook?: GatewayWebhookSettings; } +export interface CompanionMemorySettings { + enabled?: boolean; + storageDir?: string; + maxCoreTokens?: number; + maxRecallResults?: number; + writer?: { + enabled?: boolean; + maxTokens?: number; + }; +} + export type TransportSetting = Transport; /** @@ -125,6 +136,7 @@ export interface Settings { showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; gateway?: GatewaySettings; + companionMemory?: CompanionMemorySettings; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ diff --git a/packages/pi-memory-md/memory-md.ts b/packages/pi-memory-md/memory-md.ts index 178388a..6abc451 100644 --- a/packages/pi-memory-md/memory-md.ts +++ b/packages/pi-memory-md/memory-md.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import type { @@ -62,7 +61,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 { @@ -72,73 +71,12 @@ 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; - 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; + return path.join(basePath, path.basename(ctx.cwd)); } function getRepoName(settings: MemoryMdSettings): string { @@ -147,38 +85,7 @@ function getRepoName(settings: MemoryMdSettings): string { return match ? match[1] : "memory-md"; } -async function getGitHead( - pi: ExtensionAPI, - cwd: string, -): Promise { - const result = await gitExec(pi, cwd, "rev-parse", "HEAD"); - if (!result.success) { - return null; - } - const head = result.stdout.trim(); - return head.length > 0 ? head : null; -} - -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 { +function loadSettings(): MemoryMdSettings { const DEFAULT_SETTINGS: MemoryMdSettings = { enabled: true, repoUrl: "", @@ -197,34 +104,27 @@ function loadSettings(cwd?: string): MemoryMdSettings { "agent", "settings.json", ); - 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); + if (!fs.existsSync(globalSettings)) { + return DEFAULT_SETTINGS; } - return loadedSettings; + 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; + } } /** @@ -265,40 +165,12 @@ 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}`, }; } - const previousHead = await getGitHead(pi, localPath); const pullResult = await gitExec( pi, localPath, @@ -314,21 +186,15 @@ export async function syncRepository( } isRepoInitialized.value = true; - const currentHead = await getGitHead(pi, localPath); const updated = - previousHead !== null && - currentHead !== null && - previousHead !== currentHead; + pullResult.stdout.includes("Updating") || + pullResult.stdout.includes("Fast-forward"); const repoName = getRepoName(settings); - const message = - previousHead === null || currentHead === null - ? `Synchronized [${repoName}]` - : updated - ? `Pulled latest changes from [${repoName}]` - : `[${repoName}] is already latest`; return { success: true, - message, + message: updated + ? `Pulled latest changes from [${repoName}]` + : `[${repoName}] is already latest`, updated, }; } @@ -456,7 +322,7 @@ export function writeMemoryFile( * Build memory context for agent prompt. */ -export function ensureDirectoryStructure(memoryDir: string): void { +function ensureDirectoryStructure(memoryDir: string): void { const dirs = [ path.join(memoryDir, "core", "user"), path.join(memoryDir, "core", "project"), @@ -468,7 +334,7 @@ export function ensureDirectoryStructure(memoryDir: string): void { } } -export function createDefaultFiles(memoryDir: string): void { +function createDefaultFiles(memoryDir: string): void { const identityFile = path.join(memoryDir, "core", "user", "identity.md"); if (!fs.existsSync(identityFile)) { writeMemoryFile( @@ -496,68 +362,6 @@ export function createDefaultFiles(memoryDir: string): void { } } -export function formatMemoryDirectoryTree( - memoryDir: string, - maxDepth = 3, - maxLines = 40, -): string { - if (!fs.existsSync(memoryDir)) { - return "Unable to generate directory tree."; - } - - const lines = [`${path.basename(memoryDir) || memoryDir}/`]; - let truncated = false; - - function visit(dir: string, depth: number, prefix: string): void { - if (depth >= maxDepth || lines.length >= maxLines) { - truncated = true; - return; - } - - let entries: fs.Dirent[]; - try { - entries = fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.name !== "node_modules") - .sort((left, right) => { - if (left.isDirectory() !== right.isDirectory()) { - return left.isDirectory() ? -1 : 1; - } - return left.name.localeCompare(right.name); - }); - } catch { - truncated = true; - return; - } - - for (const [index, entry] of entries.entries()) { - if (lines.length >= maxLines) { - truncated = true; - return; - } - - const isLast = index === entries.length - 1; - const marker = isLast ? "\\-- " : "|-- "; - const childPrefix = `${prefix}${isLast ? " " : "| "}`; - lines.push( - `${prefix}${marker}${entry.name}${entry.isDirectory() ? "/" : ""}`, - ); - - if (entry.isDirectory()) { - visit(path.join(dir, entry.name), depth + 1, childPrefix); - } - } - } - - visit(memoryDir, 0, ""); - - if (truncated) { - lines.push("... (tree truncated)"); - } - - return lines.join("\n"); -} - function buildMemoryContext( settings: MemoryMdSettings, ctx: ExtensionContext, @@ -630,7 +434,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) { let memoryInjected = false; pi.on("session_start", async (_event, ctx) => { - settings = loadSettings(ctx.cwd); + settings = loadSettings(); if (!settings.enabled) { return; @@ -647,11 +451,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) { return; } - if ( - settings.autoSync?.onSessionStart && - settings.localPath && - settings.repoUrl - ) { + if (settings.autoSync?.onSessionStart && settings.localPath) { syncPromise = syncRepository(pi, settings, repoInitialized).then( (syncResult) => { if (settings.repoUrl) { @@ -709,20 +509,14 @@ 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( @@ -732,37 +526,12 @@ 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( @@ -775,36 +544,26 @@ 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"), ); - if (settings.repoUrl) { - const result = await syncRepository(pi, settings, repoInitialized); - if (!result.success) { - ctx.ui.notify(`Initialization failed: ${result.message}`, "error"); - return; - } + 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( - settings.repoUrl - ? "Memory already exists and repository is ready" - : "Local memory already exists", - "info", - ); + ctx.ui.notify(`Memory already exists: ${result.message}`, "info"); } else { ctx.ui.notify( - 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", + `Memory initialized: ${result.message}\n\nCreated:\n - core/user\n - core/project\n - reference`, "info", ); } @@ -814,7 +573,6 @@ 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) { @@ -852,7 +610,6 @@ 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)) { @@ -860,7 +617,25 @@ export default function memoryMdExtension(pi: ExtensionAPI) { return; } - ctx.ui.notify(formatMemoryDirectoryTree(memoryDir).trim(), "info"); + const { execSync } = await import("node:child_process"); + let treeOutput = ""; + + try { + treeOutput = execSync(`tree -L 3 -I "node_modules" "${memoryDir}"`, { + encoding: "utf-8", + }); + } catch { + try { + treeOutput = execSync( + `find "${memoryDir}" -type d -not -path "*/node_modules/*"`, + { encoding: "utf-8" }, + ); + } catch { + treeOutput = "Unable to generate directory tree."; + } + } + + ctx.ui.notify(treeOutput.trim(), "info"); }, }); } diff --git a/packages/pi-memory-md/tools.ts b/packages/pi-memory-md/tools.ts index fd479f0..a4e3425 100644 --- a/packages/pi-memory-md/tools.ts +++ b/packages/pi-memory-md/tools.ts @@ -6,22 +6,15 @@ import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js"; import { - createDefaultFiles, - ensureDirectoryStructure, - formatMemoryDirectoryTree, getCurrentDate, getMemoryDir, - getProjectRepoPath, gitExec, listMemoryFiles, readMemoryFile, - resolveMemoryPath, syncRepository, writeMemoryFile, } from "./memory-md.js"; -type MemorySettingsGetter = () => MemoryMdSettings; - function renderWithExpandHint( text: string, theme: Theme, @@ -41,7 +34,7 @@ function renderWithExpandHint( export function registerMemorySync( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, isRepoInitialized: { value: boolean }, ): void { pi.registerTool({ @@ -59,73 +52,26 @@ export function registerMemorySync( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { action } = params as { action: "pull" | "push" | "status" }; - const settings = getSettings(); - const localPath = settings.localPath; + 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 not initialized. Use memory_init to set up.", + text: "Memory repository not initialized. Use memory_init to set up.", }, ], - details: { initialized: false, configured, dirty: null }, + details: { initialized: false }, }; } - 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 result = await gitExec(pi, localPath, "status", "--porcelain"); const dirty = result.stdout.trim().length > 0; return { @@ -142,95 +88,36 @@ 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, configured: true }, + details: { success: result.success }, }; } 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 syncResult = await syncRepository(pi, settings, isRepoInitialized); - if (!syncResult.success) { - return { - content: [{ type: "text", text: syncResult.message }], - 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", "-A", "--", projectRepoPath); + await gitExec(pi, localPath, "add", "."); const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .slice(0, 19); - const commitMessage = `Update memory for ${path.basename(ctx.cwd)} - ${timestamp}`; + const commitMessage = `Update memory - ${timestamp}`; const commitResult = await gitExec( pi, localPath, "commit", "-m", commitMessage, - "--only", - "--", - projectRepoPath, ); if (!commitResult.success) { @@ -302,7 +189,7 @@ export function registerMemorySync( export function registerMemoryRead( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, ): void { pi.registerTool({ name: "memory_read", @@ -317,8 +204,8 @@ export function registerMemoryRead( async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { path: relPath } = params as { path: string }; - const settings = getSettings(); - const fullPath = resolveMemoryPath(settings, ctx, relPath); + const memoryDir = getMemoryDir(settings, ctx); + const fullPath = path.join(memoryDir, relPath); const memory = readMemoryFile(fullPath); if (!memory) { @@ -384,7 +271,7 @@ export function registerMemoryRead( export function registerMemoryWrite( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, ): void { pi.registerTool({ name: "memory_write", @@ -413,24 +300,17 @@ export function registerMemoryWrite( tags?: string[]; }; - const settings = getSettings(); - const fullPath = resolveMemoryPath(settings, ctx, relPath); + const memoryDir = getMemoryDir(settings, ctx); + const fullPath = path.join(memoryDir, relPath); const existing = readMemoryFile(fullPath); - const existingFrontmatter = existing?.frontmatter; + const existingFrontmatter = existing?.frontmatter || { description }; const frontmatter: MemoryFrontmatter = { + ...existingFrontmatter, description, - created: existingFrontmatter?.created ?? getCurrentDate(), updated: getCurrentDate(), - ...(existingFrontmatter?.limit !== undefined - ? { limit: existingFrontmatter.limit } - : {}), - ...(tags !== undefined - ? { tags } - : existingFrontmatter?.tags - ? { tags: existingFrontmatter.tags } - : {}), + ...(tags && { tags }), }; writeMemoryFile(fullPath, content, frontmatter); @@ -487,7 +367,7 @@ export function registerMemoryWrite( export function registerMemoryList( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, ): void { pi.registerTool({ name: "memory_list", @@ -501,11 +381,8 @@ 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 - ? resolveMemoryPath(settings, ctx, directory) - : memoryDir; + const searchDir = directory ? path.join(memoryDir, directory) : memoryDir; const files = listMemoryFiles(searchDir); const relPaths = files.map((f) => path.relative(memoryDir, f)); @@ -555,7 +432,7 @@ export function registerMemoryList( export function registerMemorySearch( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, ): void { pi.registerTool({ name: "memory_search", @@ -580,7 +457,6 @@ 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 }> = []; @@ -668,7 +544,7 @@ export function registerMemorySearch( export function registerMemoryInit( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, isRepoInitialized: { value: boolean }, ): void { pi.registerTool({ @@ -682,19 +558,10 @@ 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 (alreadyInitialized && (!settings.repoUrl || repoReady) && !force) { + if (isRepoInitialized.value && !force) { return { content: [ { @@ -706,35 +573,18 @@ export function registerMemoryInit( }; } - 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; + const result = await syncRepository(pi, settings, isRepoInitialized); return { content: [ { type: "text", - 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")}`, + 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}`, }, ], - details: { success: true }, + details: { success: result.success }, }; }, @@ -778,7 +628,7 @@ export function registerMemoryInit( export function registerMemoryCheck( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, ): void { pi.registerTool({ name: "memory_check", @@ -787,7 +637,6 @@ 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)) { @@ -802,7 +651,26 @@ export function registerMemoryCheck( }; } - const treeOutput = formatMemoryDirectoryTree(memoryDir); + const { execSync } = await import("node:child_process"); + let treeOutput = ""; + + try { + treeOutput = execSync(`tree -L 3 -I "node_modules" "${memoryDir}"`, { + encoding: "utf-8", + }); + } catch { + try { + treeOutput = execSync( + `find "${memoryDir}" -type d -not -path "*/node_modules/*" | head -20`, + { + encoding: "utf-8", + }, + ); + } catch { + treeOutput = + "Unable to generate directory tree. Please check permissions."; + } + } const files = listMemoryFiles(memoryDir); const relPaths = files.map((f) => path.relative(memoryDir, f)); @@ -851,14 +719,14 @@ export function registerMemoryCheck( export function registerAllTools( pi: ExtensionAPI, - getSettings: MemorySettingsGetter, + settings: MemoryMdSettings, isRepoInitialized: { value: boolean }, ): void { - registerMemorySync(pi, getSettings, isRepoInitialized); - registerMemoryRead(pi, getSettings); - registerMemoryWrite(pi, getSettings); - registerMemoryList(pi, getSettings); - registerMemorySearch(pi, getSettings); - registerMemoryInit(pi, getSettings, isRepoInitialized); - registerMemoryCheck(pi, getSettings); + registerMemorySync(pi, settings, isRepoInitialized); + registerMemoryRead(pi, settings); + registerMemoryWrite(pi, settings); + registerMemoryList(pi, settings); + registerMemorySearch(pi, settings); + registerMemoryInit(pi, settings, isRepoInitialized); + registerMemoryCheck(pi, settings); } diff --git a/public-install.sh b/public-install.sh index b19e8b8..f04391a 100755 --- a/public-install.sh +++ b/public-install.sh @@ -23,7 +23,6 @@ SERVICE_STDERR_LOG="" DEFAULT_PACKAGES=( "npm:@e9n/pi-channels" - "npm:pi-memory-md" "npm:pi-teams" )