'use strict'; const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety'); const { MAX_BODY_BYTES, MAX_DOCUMENT_BYTES, MEMORY_KINDS, MEMORY_SCHEMA_VERSION, MEMORY_SCOPES, MEMORY_STATUSES, MEMORY_TRUST_STATES, asNonEmptyString, decodeUtf8, findPotentialSecrets, hasUnsafeControlCharacters, normalizeMemory, parseMemoryDocument, serializeMemoryDocument, uniqueStrings, validateEnum, validateMemoryId, validateSlug, } = require('./memory-vault-format'); const DEFAULT_RECALL_SCOPES = Object.freeze(['project', 'team']); const MAX_FILES = 5000; const MAX_SCAN_BYTES = 16 * 1024 * 1024; const MAX_DIAGNOSTICS = 100; const MAX_QUERY_CHARS = 500; const MAX_RESULTS = 100; const PROJECT_MEMORY_GITIGNORE = '*\n!.gitignore\n'; const VAULT_ROOT_BOUNDARIES = Symbol('vaultRootBoundaries'); function findNearestProjectRoot(cwd) { let current = path.resolve(cwd); while (true) { if (fs.existsSync(path.join(current, '.git'))) { return current; } const parent = path.dirname(current); if (parent === current) { return path.resolve(cwd); } current = parent; } } function resolveOverride(value, cwd) { return path.resolve(cwd, asNonEmptyString(value, 'memory root override', 4096)); } function resolveVaultRoots(options = {}) { const cwd = path.resolve(options.cwd || process.cwd()); const env = options.env || process.env; const homeDir = path.resolve( options.homeDir || env.HOME || env.USERPROFILE || os.homedir() ); const projectRoot = findNearestProjectRoot(cwd); const projectVault = env.ECC_MEMORY_PROJECT_ROOT ? resolveOverride(env.ECC_MEMORY_PROJECT_ROOT, cwd) : path.join(projectRoot, '.ecc', 'memory'); const userVault = env.ECC_MEMORY_USER_ROOT ? resolveOverride(env.ECC_MEMORY_USER_ROOT, cwd) : path.join(homeDir, '.ecc', 'memory'); const roots = { project: path.join(projectVault, 'project'), team: path.join(projectVault, 'team'), user: userVault, }; Object.defineProperty(roots, VAULT_ROOT_BOUNDARIES, { value: Object.freeze({ project: env.ECC_MEMORY_PROJECT_ROOT ? realpathNearestExisting(projectVault) : projectRoot, team: env.ECC_MEMORY_PROJECT_ROOT ? realpathNearestExisting(projectVault) : projectRoot, user: env.ECC_MEMORY_USER_ROOT ? realpathNearestExisting(userVault) : homeDir, }), enumerable: false, configurable: false, writable: false, }); return Object.freeze(roots); } function assertMemoryRootSafe(roots, scope) { if (!roots || typeof roots !== 'object' || Array.isArray(roots)) { throw new Error('Memory roots must include a trusted boundary policy.'); } const root = roots[scope]; if (typeof root !== 'string' || root.length === 0) { throw new Error(`No memory root is configured for scope "${scope}".`); } const boundary = roots[VAULT_ROOT_BOUNDARIES]?.[scope]; if (typeof boundary !== 'string' || boundary.length === 0) { throw new Error(`No trusted boundary policy is configured for memory scope "${scope}".`); } assertWithinTrustedRoot(root, boundary, 'access memory through a symlink'); if (fs.existsSync(root) && fs.lstatSync(root).isSymbolicLink()) { throw new Error(`Refusing to access memory through symlink root: ${root}`); } return root; } function assertMemoryDirectorySafe(directory, root) { assertWithinTrustedRoot(directory, root, 'access memory directory'); if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) { throw new Error(`Refusing to access memory through symlink directory: ${directory}`); } return directory; } function sameFileIdentity(left, right) { // The inode is the primary identity signal and must always match. if (left.ino !== right.ino) { return false; } // libuv 1.49.0 through 1.50.x resolve path-based stat() and lstat() on Windows // through GetFileInformationByName, which leaves the volume serial unset, while // fstat() on an open handle reports it. Comparing the two then never matches and // every vault read and write is rejected. libuv 82cdfb75f fixed this in 1.51.0, // so only Node 22.12-22.16 and 24.0-24.1 are affected, but the guard should not // depend on the runtime's patch level. Compare dev only when both sides report // one; POSIX always does, so the original strict behaviour is preserved there. if (!left.dev || !right.dev) { return true; } return left.dev === right.dev; } function readRegularTextFile(filePath, options = {}) { const label = options.label || 'file'; const maxBytes = options.maxBytes || MAX_DOCUMENT_BYTES; if (options.trustedRoot) { assertWithinTrustedRoot(filePath, options.trustedRoot, `read ${label}`); } const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0); const descriptor = fs.openSync(filePath, flags); try { const opened = fs.fstatSync(descriptor, { bigint: true }); if (!opened.isFile()) { throw new Error(`${label} must be a regular, non-symlink file.`); } const after = fs.lstatSync(filePath, { bigint: true }); if ( after.isSymbolicLink() || !after.isFile() || !sameFileIdentity(after, opened) ) { throw new Error(`${label} must remain a regular, non-symlink file while it is opened.`); } if (options.trustedRoot) { assertWithinTrustedRoot(filePath, options.trustedRoot, `read ${label}`); } if (opened.size > BigInt(maxBytes)) { throw new Error(`${label} is too large (${opened.size} bytes).`); } const chunks = []; let total = 0; while (total <= maxBytes) { const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total)); const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); if (bytesRead === 0) break; chunks.push(buffer.subarray(0, bytesRead)); total += bytesRead; } if (total > maxBytes) { throw new Error(`${label} is too large (maximum ${maxBytes} bytes).`); } return decodeUtf8(Buffer.concat(chunks, total), label); } finally { fs.closeSync(descriptor); } } function writeCreateOnlyTextFile(filePath, content, trustedRoot) { assertWithinTrustedRoot(filePath, trustedRoot, 'write memory'); const temporaryPath = path.join( path.dirname(filePath), `.ecc-memory-${process.pid}-${crypto.randomUUID()}.tmp` ); const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0); let descriptor; let operationError; let cleanupError; try { descriptor = fs.openSync(temporaryPath, flags, 0o600); const opened = fs.fstatSync(descriptor, { bigint: true }); const after = fs.lstatSync(temporaryPath, { bigint: true }); assertWithinTrustedRoot(temporaryPath, trustedRoot, 'write memory'); if ( !opened.isFile() || after.isSymbolicLink() || !after.isFile() || !sameFileIdentity(after, opened) ) { throw new Error('Memory destination changed while it was being created.'); } fs.writeFileSync(descriptor, content, 'utf8'); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; assertWithinTrustedRoot(filePath, trustedRoot, 'write memory'); fs.linkSync(temporaryPath, filePath); } catch (error) { operationError = error; } finally { if (descriptor !== undefined) { try { fs.closeSync(descriptor); } catch (error) { cleanupError = error; } } try { fs.unlinkSync(temporaryPath); } catch (error) { if (!error || error.code !== 'ENOENT') cleanupError = cleanupError || error; } } if (operationError) throw operationError; if (cleanupError) throw cleanupError; } function ensureProjectScopeIgnored(roots, scope) { if (scope !== 'project') return; const root = roots.project; const ignorePath = path.join(root, '.gitignore'); try { writeCreateOnlyTextFile(ignorePath, PROJECT_MEMORY_GITIGNORE, root); } catch (error) { if (!error || error.code !== 'EEXIST') throw error; const existing = readRegularTextFile(ignorePath, { label: 'project memory .gitignore', maxBytes: MAX_DOCUMENT_BYTES, trustedRoot: root, }); if (existing !== PROJECT_MEMORY_GITIGNORE) { throw new Error( 'Project memory .gitignore does not contain the required fail-closed rules.' ); } } } function normalizeScopes(scopes = MEMORY_SCOPES) { const values = Array.isArray(scopes) ? scopes : [scopes]; return uniqueStrings(values, { label: 'scopes', limit: MEMORY_SCOPES.length, validator: value => validateEnum(value, MEMORY_SCOPES, 'memory scope'), }); } function initializeVault(options = {}) { const roots = options.roots || resolveVaultRoots(options); const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES); const directories = scopes.flatMap(scope => { const root = assertMemoryRootSafe(roots, scope); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); ensureProjectScopeIgnored(roots, scope); return MEMORY_KINDS.map(kind => { const directory = path.join(root, `${kind}s`); assertMemoryDirectorySafe(directory, root); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); return directory; }); }); return { scopes, roots, directories }; } function defaultMemoryId(now = new Date()) { const day = now.toISOString().slice(0, 10).replace(/-/g, ''); const random = crypto.randomUUID().replace(/-/g, '').slice(0, 20); return `mem_${day}_${random}`; } function normalizeSaveInput(input, options) { const now = options.now ? options.now() : new Date().toISOString(); const id = input.id || ( options.idFactory ? options.idFactory() : defaultMemoryId(new Date(now)) ); return normalizeMemory({ schema: MEMORY_SCHEMA_VERSION, id, title: input.title, kind: input.kind || 'note', scope: input.scope || 'project', trust: 'unreviewed', status: 'active', sourceHarness: input.sourceHarness || 'unknown', targetHarnesses: input.targetHarnesses || ['all'], tags: input.tags || [], links: input.links || [], createdAt: now, updatedAt: now, body: input.body || '', }); } function saveMemory(input, options = {}) { const roots = options.roots || resolveVaultRoots(options); const memory = normalizeSaveInput(input || {}, options); const secretKinds = findPotentialSecrets(JSON.stringify(memory)); if (secretKinds.length > 0) { throw new Error(`Refusing to save memory containing a suspected secret (${secretKinds.join(', ')}).`); } const root = assertMemoryRootSafe(roots, memory.scope); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); ensureProjectScopeIgnored(roots, memory.scope); const directory = path.join(root, `${memory.kind}s`); assertMemoryDirectorySafe(directory, root); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); const destination = path.join(directory, `${memory.id}.md`); try { writeCreateOnlyTextFile(destination, serializeMemoryDocument(memory), root); } catch (error) { if (error && error.code === 'EEXIST') { throw new Error(`Memory ${memory.id} already exists; writes are create-only.`); } throw error; } return { memory, path: destination }; } function walkMemoryRoot(root, maxEntries = MAX_FILES) { if (!root || !fs.existsSync(root)) { return { paths: [], skippedSymlinks: [], skippedSymlinkCount: 0, truncated: false, visitedCount: 0, }; } const paths = []; const skippedSymlinks = []; let skippedSymlinkCount = 0; let visitedCount = 0; let truncated = false; const walk = (directory, depth) => { if (depth > 8 || visitedCount >= maxEntries) { truncated = true; return; } const handle = fs.opendirSync(directory); const entries = []; try { while (entries.length < maxEntries - visitedCount) { const entry = handle.readSync(); if (!entry) break; entries.push(entry); } if (handle.readSync() !== null) truncated = true; } finally { handle.closeSync(); } entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { if (visitedCount >= maxEntries) { truncated = true; break; } visitedCount += 1; const entryPath = path.join(directory, entry.name); if (entry.isSymbolicLink()) { skippedSymlinkCount += 1; if (skippedSymlinks.length < MAX_DIAGNOSTICS) { skippedSymlinks.push(entryPath); } continue; } if (entry.isDirectory() && !entry.name.startsWith('.')) { walk(entryPath, depth + 1); continue; } const include = entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'); if (include) paths.push(entryPath); } }; walk(root, 0); return { paths, skippedSymlinks, skippedSymlinkCount, truncated, visitedCount, }; } function vaultRelativePath(scope, root, filePath) { const relative = path.relative(root, filePath).split(path.sep).join('/'); return `${scope}:${relative}`; } function assertMemoryMatchesLocation(memory, scope, root, filePath) { const [kindDirectory] = path.relative(root, filePath).split(path.sep); if (memory.scope !== scope || kindDirectory !== `${memory.kind}s`) { const error = new Error('Memory metadata does not match its vault location.'); error.code = 'ECC_MEMORY_LOCATION_MISMATCH'; throw error; } } function publicMemoryFileError(error) { if (error?.code === 'ECC_MEMORY_SECRET') { return { code: 'suspected-secret', message: 'Memory document was quarantined.' }; } if (error?.code === 'ECC_MEMORY_LOCATION_MISMATCH') { return { code: 'location-mismatch', message: 'Memory metadata does not match its vault location.', }; } return { code: 'invalid-document', message: 'Memory document is invalid or unreadable.', }; } function readMemoryFiles(options = {}) { const roots = options.roots || resolveVaultRoots(options); const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES); const entries = []; const invalidFiles = []; const skippedSymlinks = []; let invalidFileCount = 0; let skippedSymlinkCount = 0; let visitedCount = 0; let scannedBytes = 0; let truncated = false; for (const scope of scopes) { if (visitedCount >= MAX_FILES || scannedBytes >= MAX_SCAN_BYTES) { truncated = true; break; } const root = assertMemoryRootSafe(roots, scope); const walked = walkMemoryRoot(root, MAX_FILES - visitedCount); visitedCount += walked.visitedCount; truncated = truncated || walked.truncated; skippedSymlinkCount += walked.skippedSymlinkCount; for (const skippedPath of walked.skippedSymlinks) { if (skippedSymlinks.length >= MAX_DIAGNOSTICS) break; skippedSymlinks.push(vaultRelativePath(scope, root, skippedPath)); } for (const filePath of walked.paths) { if (scannedBytes >= MAX_SCAN_BYTES) { truncated = true; break; } try { const source = readRegularTextFile(filePath, { label: 'memory document', maxBytes: MAX_DOCUMENT_BYTES, trustedRoot: root, }); const sourceBytes = Buffer.byteLength(source, 'utf8'); if (scannedBytes + sourceBytes > MAX_SCAN_BYTES) { truncated = true; break; } scannedBytes += sourceBytes; const memory = parseMemoryDocument(source, filePath); assertMemoryMatchesLocation(memory, scope, root, filePath); if (findPotentialSecrets(JSON.stringify(memory)).length > 0) { const error = new Error('Memory contains a suspected secret.'); error.code = 'ECC_MEMORY_SECRET'; throw error; } entries.push({ memory, path: vaultRelativePath(scope, root, filePath), }); } catch (error) { invalidFileCount += 1; if (invalidFiles.length < MAX_DIAGNOSTICS) { invalidFiles.push({ path: vaultRelativePath(scope, root, filePath), ...publicMemoryFileError(error), }); } } } } return { entries, invalidFiles, invalidFileCount, skippedSymlinks, skippedSymlinkCount, scannedBytes, truncated, diagnosticsTruncated: invalidFileCount > invalidFiles.length || skippedSymlinkCount > skippedSymlinks.length, }; } function tokenize(value) { return String(value || '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || []; } function countOccurrences(haystack, needle) { if (!needle) return 0; let count = 0; let offset = 0; while (count < 8) { const index = haystack.indexOf(needle, offset); if (index < 0) break; count += 1; offset = index + needle.length; } return count; } function scoreMemory(memory, query) { const normalizedQuery = query.toLowerCase(); const tokens = Array.from(new Set(tokenize(query))); const title = memory.title.toLowerCase(); const body = memory.body.toLowerCase(); const tags = memory.tags.map(tag => tag.toLowerCase()); const metadata = [ memory.kind, memory.scope, memory.sourceHarness, ...memory.targetHarnesses, ].join(' ').toLowerCase(); const phraseScore = normalizedQuery && title.includes(normalizedQuery) ? 20 : normalizedQuery && body.includes(normalizedQuery) ? 5 : 0; return tokens.reduce((score, token) => ( score + (title.includes(token) ? 8 : 0) + (tags.includes(token) ? 6 : 0) + (metadata.includes(token) ? 3 : 0) + Math.min(countOccurrences(body, token), 5) ), phraseScore); } function buildExcerpt(body, query, maxChars = 240) { const normalized = String(body || '').replace(/\s+/g, ' ').trim(); if (normalized.length <= maxChars) return normalized; const tokens = tokenize(query); const lower = normalized.toLowerCase(); const matchIndex = tokens.reduce((best, token) => { const index = lower.indexOf(token); if (index < 0) return best; return best < 0 ? index : Math.min(best, index); }, -1); const start = Math.max(0, (matchIndex < 0 ? 0 : matchIndex) - 60); const prefix = start > 0 ? '…' : ''; const suffix = start + maxChars < normalized.length ? '…' : ''; return `${prefix}${normalized.slice(start, start + maxChars)}${suffix}`; } function summarizeMemory(memory) { return Object.fromEntries( Object.entries(memory).filter(([key]) => key !== 'body') ); } function searchMemories(query, options = {}) { const normalizedQuery = typeof query === 'string' ? query.trim() : ''; if (normalizedQuery.length > MAX_QUERY_CHARS) { throw new Error(`memory search query is too long (maximum ${MAX_QUERY_CHARS} characters).`); } if (hasUnsafeControlCharacters(normalizedQuery)) { throw new Error('memory search query must not contain control characters.'); } const kinds = options.kinds ? uniqueStrings(options.kinds, { label: 'kinds', limit: MEMORY_KINDS.length, validator: value => validateEnum(value, MEMORY_KINDS, 'memory kind'), }) : null; const trust = options.trust ? validateEnum(options.trust, MEMORY_TRUST_STATES, 'memory trust') : null; const targetHarness = options.targetHarness ? validateSlug(options.targetHarness, 'target harness') : null; const limit = Math.max(1, Math.min(Number(options.limit) || 20, MAX_RESULTS)); const loaded = readMemoryFiles({ ...options, scopes: options.scopes || options.scope }); const results = loaded.entries .filter(({ memory }) => memory.status === 'active') .filter(({ memory }) => !kinds || kinds.includes(memory.kind)) .filter(({ memory }) => !trust || memory.trust === trust) .filter(({ memory }) => ( !targetHarness || memory.targetHarnesses.includes('all') || memory.targetHarnesses.includes(targetHarness) )) .map(entry => ({ ...entry, score: normalizedQuery ? scoreMemory(entry.memory, normalizedQuery) : 0, excerpt: buildExcerpt(entry.memory.body, normalizedQuery), })) .filter(result => normalizedQuery.length === 0 || result.score > 0) .sort((left, right) => ( right.score - left.score || right.memory.updatedAt.localeCompare(left.memory.updatedAt) || left.memory.id.localeCompare(right.memory.id) )) .slice(0, limit) .map(result => ({ memory: summarizeMemory(result.memory), score: result.score, excerpt: result.excerpt, })); return { results, diagnostics: { invalidFiles: loaded.invalidFiles, invalidFileCount: loaded.invalidFileCount, skippedSymlinks: loaded.skippedSymlinks, skippedSymlinkCount: loaded.skippedSymlinkCount, scannedBytes: loaded.scannedBytes, truncated: loaded.truncated, diagnosticsTruncated: loaded.diagnosticsTruncated, }, }; } function readMemoryById(id, options = {}) { const memoryId = validateMemoryId(id); const targetHarness = options.targetHarness ? validateSlug(options.targetHarness, 'target harness') : null; const loaded = readMemoryFiles(options); const matches = loaded.entries .filter(entry => entry.memory.id === memoryId) .filter(entry => ( !targetHarness || entry.memory.targetHarnesses.includes('all') || entry.memory.targetHarnesses.includes(targetHarness) )); if (matches.length === 0) { throw new Error(`Memory ${memoryId} was not found.`); } if (matches.length > 1) { throw new Error(`Memory ${memoryId} is duplicated in ${matches.length} files.`); } const allBacklinks = loaded.entries .filter(entry => entry.memory.links.includes(memoryId)) .filter(entry => entry.memory.status === 'active') .map(entry => entry.memory) .filter(memory => ( !targetHarness || memory.targetHarnesses.includes('all') || memory.targetHarnesses.includes(targetHarness) )) .sort((left, right) => left.id.localeCompare(right.id)); const backlinks = allBacklinks .slice(0, MAX_RESULTS) .map(summarizeMemory); return { ...matches[0], backlinks, backlinksTruncated: allBacklinks.length > backlinks.length, }; } function doctorMemoryVault(options = {}) { const loaded = readMemoryFiles(options); const targetHarness = options.targetHarness ? validateSlug(options.targetHarness, 'target harness') : null; const visibleEntries = loaded.entries.filter(entry => ( !targetHarness || entry.memory.targetHarnesses.includes('all') || entry.memory.targetHarnesses.includes(targetHarness) )); const byId = new Map(); for (const entry of visibleEntries) { const paths = byId.get(entry.memory.id) || []; paths.push(entry.path); byId.set(entry.memory.id, paths); } const allDuplicateIds = Array.from(byId.entries()) .filter(([, paths]) => paths.length > 1) .map(([id, paths]) => ({ id, paths })) .sort((left, right) => left.id.localeCompare(right.id)); const duplicateIds = allDuplicateIds.slice(0, MAX_DIAGNOSTICS); const knownIds = new Set(byId.keys()); const allBrokenLinks = []; let brokenLinkCount = 0; for (const entry of visibleEntries) { for (const targetId of entry.memory.links) { if (!knownIds.has(targetId)) { brokenLinkCount += 1; if (allBrokenLinks.length < MAX_DIAGNOSTICS) { allBrokenLinks.push({ sourceId: entry.memory.id, targetId, path: entry.path, }); } } } } const brokenLinks = [...allBrokenLinks] .sort((left, right) => left.sourceId.localeCompare(right.sourceId)); const ok = loaded.invalidFileCount === 0 && allDuplicateIds.length === 0 && brokenLinkCount === 0 && loaded.skippedSymlinkCount === 0 && !loaded.truncated; return { schemaVersion: 'ecc.memory.doctor.v1', ok, memoryCount: visibleEntries.length, invalidFiles: loaded.invalidFiles, invalidFileCount: loaded.invalidFileCount, duplicateIds, duplicateIdCount: allDuplicateIds.length, brokenLinks, brokenLinkCount, skippedSymlinks: loaded.skippedSymlinks, skippedSymlinkCount: loaded.skippedSymlinkCount, scannedBytes: loaded.scannedBytes, truncated: loaded.truncated, diagnosticsTruncated: loaded.diagnosticsTruncated || allDuplicateIds.length > duplicateIds.length || brokenLinkCount > brokenLinks.length, }; } module.exports = { DEFAULT_RECALL_SCOPES, MAX_BODY_BYTES, MAX_DIAGNOSTICS, MAX_DOCUMENT_BYTES, MAX_FILES, MAX_QUERY_CHARS, MAX_RESULTS, MAX_SCAN_BYTES, MEMORY_KINDS, MEMORY_SCHEMA_VERSION, MEMORY_SCOPES, MEMORY_STATUSES, MEMORY_TRUST_STATES, defaultMemoryId, decodeUtf8, doctorMemoryVault, findPotentialSecrets, findNearestProjectRoot, initializeVault, normalizeMemory, parseMemoryDocument, readRegularTextFile, readMemoryById, readMemoryFiles, resolveVaultRoots, sameFileIdentity, saveMemory, scoreMemory, searchMemories, serializeMemoryDocument, tokenize, };