diff --git a/.agents/skills/unified-memory/SKILL.md b/.agents/skills/unified-memory/SKILL.md index 11a585513..35feac2fd 100644 --- a/.agents/skills/unified-memory/SKILL.md +++ b/.agents/skills/unified-memory/SKILL.md @@ -9,6 +9,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The vault stores portable `ecc.memory.v1` Markdown documents rather than harness-specific transcripts or inboxes. +## Runtime Prerequisite + +This skill is guidance, not the Memory Vault executable. Skill-only, minimal, +manual, and Claude plugin installs do not create the required commands on +`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI +or MCP examples: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +A repository checkout may instead run the CLI as +`node scripts/ecc.js memory ...`, but MCP configurations that name +`ecc-memory-mcp` still require that binary on `PATH`. + ## When To Use - Save durable context that another agent or later session will need. diff --git a/.cursor/skills/unified-memory/SKILL.md b/.cursor/skills/unified-memory/SKILL.md index 364d98846..83a670768 100644 --- a/.cursor/skills/unified-memory/SKILL.md +++ b/.cursor/skills/unified-memory/SKILL.md @@ -10,6 +10,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The vault stores portable `ecc.memory.v1` Markdown documents rather than harness-specific transcripts or inboxes. +## Runtime Prerequisite + +This skill is guidance, not the Memory Vault executable. Skill-only, minimal, +manual, and Claude plugin installs do not create the required commands on +`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI +or MCP examples: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +A repository checkout may instead run the CLI as +`node scripts/ecc.js memory ...`, but MCP configurations that name +`ecc-memory-mcp` still require that binary on `PATH`. + ## When To Use - Save durable context that another agent or later session will need. diff --git a/README.md b/README.md index 3e507c053..86108562f 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,16 @@ team scopes live under `.ecc/memory/`; user scope lives under use the team scope only for human-inspected, version-controlled sharing. Team memories remain unreviewed context even after they are committed. +Skill-only, minimal, manual, and Claude plugin installs do not put the Memory +Vault runtime on `PATH`. Install the npm runtime separately before using the CLI +or optional MCP server: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + ```bash # Initialize the project vault. ecc memory init --scope project diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d962a850..49be12379 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,6 +107,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec `ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。 该服务默认不会启用。 +仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入 +`PATH`。请先单独安装 ECC npm 运行时: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制 +`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务 +进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享 +同一个二进制文件和记忆库目录,但不能共用同一个服务进程。 + ## 快速开始 在 2 分钟内快速上手: diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 6a2571692..154964148 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -54,7 +54,16 @@ Use this as the minimal surface to reproduce the setup without leaking private s ECC Memory Vault provides one file-first handoff layer instead of a separate inbox or transcript store for every agent. Initialize it from the repository -that the agents share: +that the agents share. Skill-only, minimal, manual, and Claude plugin installs +do not add the Memory Vault runtime to `PATH`; install it separately first: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +Then initialize the vault: ```bash ecc memory init --scope project --scope team @@ -68,10 +77,12 @@ unreviewed context; human acceptance means promoting verified knowledge into governed project documentation. Hermes can call the CLI directly or use the opt-in `ecc-memory-mcp` stdio -server. Claude, Codex, Cursor, and OpenCode can connect to the same server or -use the same CLI. Every harness must launch from the same repository working -directory or receive identical `ECC_MEMORY_PROJECT_ROOT` and -`ECC_MEMORY_USER_ROOT` overrides. +server. Harnesses may share the same installed binary and vault storage, but +each harness must launch its own server process with its own distinct lowercase +`ECC_MEMORY_HARNESS` identity; they must not connect to one shared server +process. Every process must launch from the same repository working directory +or receive identical `ECC_MEMORY_PROJECT_ROOT` and `ECC_MEMORY_USER_ROOT` +overrides. A Hermes-to-Codex handoff can be written without putting the body in the process list: diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 4d4d7594d..68a374644 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -177,6 +177,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec `ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。 该服务默认不会启用。 +仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入 +`PATH`。请先单独安装 ECC npm 运行时: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制 +`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务 +进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享 +同一个二进制文件和记忆库目录,但不能共用同一个服务进程。 + ## 快速开始 在 2 分钟内启动并运行: diff --git a/manifests/install-components.json b/manifests/install-components.json index 4e545d75d..70409a86d 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -470,6 +470,14 @@ "workflow-quality" ] }, + { + "id": "skill:unified-memory", + "family": "skill", + "description": "Cross-harness memory guidance that requires the separately installed ecc-universal CLI runtime.", + "modules": [ + "skill-unified-memory" + ] + }, { "id": "skill:tdd-workflow", "family": "skill", diff --git a/manifests/install-modules.json b/manifests/install-modules.json index ce3dfa930..6ba1d3093 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -272,10 +272,40 @@ "cost": "medium", "stability": "stable" }, + { + "id": "skill-unified-memory", + "kind": "skills", + "description": "Single-skill unified-memory guidance; requires the separately installed ecc-universal CLI runtime.", + "paths": [ + "skills/unified-memory" + ], + "targets": [ + "claude", + "claude-project", + "cursor", + "antigravity", + "codex", + "gemini", + "opencode", + "codebuddy", + "joycode", + "qwen", + "zed", + "hermes", + "openclaw", + "kimi" + ], + "dependencies": [ + "platform-configs" + ], + "defaultInstall": false, + "cost": "light", + "stability": "stable" + }, { "id": "workflow-quality", "kind": "skills", - "description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path.", + "description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path. The unified-memory workflow requires the separately installed ecc-universal CLI runtime.", "paths": [ "skills/agent-sort", "skills/agent-introspection-debugging", @@ -297,7 +327,6 @@ "skills/skill-stocktake", "skills/strategic-compact", "skills/tdd-workflow", - "skills/unified-memory", "skills/verification-loop", "skills/windows-desktop-e2e", "skills/agent-self-evaluation", @@ -338,7 +367,7 @@ "kimi" ], "dependencies": [ - "platform-configs" + "skill-unified-memory" ], "defaultInstall": true, "cost": "medium", diff --git a/manifests/install-profiles.json b/manifests/install-profiles.json index b7a17109e..15e429943 100644 --- a/manifests/install-profiles.json +++ b/manifests/install-profiles.json @@ -81,6 +81,7 @@ "framework-language", "database", "workflow-quality", + "skill-unified-memory", "security", "research-apis", "business-content", diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index 1831ece69..62029c0b6 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -44,7 +44,7 @@ "ecc-memory-vault": { "command": "ecc-memory-mcp", "env": { - "ECC_MEMORY_HARNESS": "REPLACE_WITH_LOWERCASE_HARNESS_SLUG" + "ECC_MEMORY_HARNESS": "YOUR_LOWERCASE_HARNESS_SLUG_HERE" }, "description": "Opt-in local ECC Memory Vault shared by Claude, Codex, Hermes, Cursor, OpenCode, and other MCP clients. Replace ECC_MEMORY_HARNESS with this server's lowercase identity; callers cannot override it. Normal search recall is active project+team memory. To permit explicitly requested user scope, the operator may also set ECC_MEMORY_ALLOW_USER_SCOPE=1. Writes are create-only and always unreviewed. Install ECC globally or make its bin available on PATH. Not enabled by default." }, diff --git a/scripts/lib/memory-vault-format.js b/scripts/lib/memory-vault-format.js index a23b49265..006f32e30 100644 --- a/scripts/lib/memory-vault-format.js +++ b/scripts/lib/memory-vault-format.js @@ -1,5 +1,7 @@ 'use strict'; +const { TextDecoder } = require('util'); + const MEMORY_SCHEMA_VERSION = 'ecc.memory.v1'; const MEMORY_KINDS = Object.freeze([ 'context', @@ -42,6 +44,7 @@ const FRONTMATTER_FIELDS = Object.freeze([ ['updated_at', 'updatedAt'], ]); const FRONTMATTER_KEYS = new Map(FRONTMATTER_FIELDS); +const FATAL_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); const SECRET_PATTERNS = Object.freeze([ { label: 'provider API key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/i }, @@ -208,6 +211,14 @@ function serializeMemoryDocument(memory) { return `---\n${metadata}\n---${body}\n`; } +function decodeUtf8(buffer, label = 'text') { + try { + return FATAL_UTF8_DECODER.decode(buffer); + } catch { + throw new Error(`${label} must contain valid UTF-8 text.`); + } +} + function parseFrontmatterLine(line, sourcePath, seen) { const separator = line.indexOf(':'); if (separator <= 0) { @@ -230,19 +241,24 @@ function parseFrontmatterLine(line, sourcePath, seen) { } function parseMemoryDocument(source, sourcePath = '') { - if (typeof source !== 'string' || !source.startsWith('---\n')) { + const openingMarker = typeof source === 'string' + ? /^---\r?\n/.exec(source) + : null; + if (!openingMarker) { throw new Error(`Memory document ${sourcePath} must start with --- frontmatter.`); } if (Buffer.byteLength(source, 'utf8') > MAX_DOCUMENT_BYTES) { throw new Error(`Memory document ${sourcePath} is too large.`); } - const closingIndex = source.indexOf('\n---', 4); - if (closingIndex < 0) { + const frontmatterStart = openingMarker[0].length; + const remainder = source.slice(frontmatterStart); + const closingMarker = /\r?\n---(?=\r?\n|$)/.exec(remainder); + if (!closingMarker) { throw new Error(`Memory document ${sourcePath} has no closing frontmatter marker.`); } - const frontmatterSource = source.slice(4, closingIndex); + const frontmatterSource = remainder.slice(0, closingMarker.index); const parsed = frontmatterSource.split(/\r?\n/).reduce((state, line) => { const next = parseFrontmatterLine(line, sourcePath, state.seen); return { @@ -258,7 +274,7 @@ function parseMemoryDocument(source, sourcePath = '') { throw new Error(`Memory document ${sourcePath} is missing fields: ${missing.join(', ')}.`); } - const afterMarker = source.slice(closingIndex + 4); + const afterMarker = remainder.slice(closingMarker.index + closingMarker[0].length); const body = afterMarker.replace(/^\r?\n/, '').replace(/\r?\n$/, ''); return normalizeMemory({ ...parsed.values, body }); } @@ -280,6 +296,7 @@ module.exports = { MEMORY_STATUSES, MEMORY_TRUST_STATES, asNonEmptyString, + decodeUtf8, findPotentialSecrets, hasUnsafeControlCharacters, normalizeMemory, diff --git a/scripts/lib/memory-vault.js b/scripts/lib/memory-vault.js index 9f24fe20a..591737017 100644 --- a/scripts/lib/memory-vault.js +++ b/scripts/lib/memory-vault.js @@ -5,7 +5,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { assertWithinTrustedRoot } = require('./path-safety'); +const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety'); const { MAX_BODY_BYTES, MAX_DOCUMENT_BYTES, @@ -15,6 +15,7 @@ const { MEMORY_STATUSES, MEMORY_TRUST_STATES, asNonEmptyString, + decodeUtf8, findPotentialSecrets, hasUnsafeControlCharacters, normalizeMemory, @@ -35,7 +36,7 @@ const MAX_QUERY_CHARS = 500; const MAX_RESULTS = 100; const PROJECT_MEMORY_GITIGNORE = '*\n!.gitignore\n'; -const VAULT_ROOT_POLICIES = new WeakMap(); +const VAULT_ROOT_BOUNDARIES = Symbol('vaultRootBoundaries'); function findNearestProjectRoot(cwd) { let current = path.resolve(cwd); @@ -74,23 +75,38 @@ function resolveVaultRoots(options = {}) { team: path.join(projectVault, 'team'), user: userVault, }; - VAULT_ROOT_POLICIES.set(roots, { - project: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot, - team: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot, - user: env.ECC_MEMORY_USER_ROOT ? null : homeDir, + 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 roots; + 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 = VAULT_ROOT_POLICIES.get(roots)?.[scope]; - if (boundary) { - assertWithinTrustedRoot(root, boundary, 'access memory through a symlink'); + 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}`); } @@ -152,7 +168,7 @@ function readRegularTextFile(filePath, options = {}) { if (total > maxBytes) { throw new Error(`${label} is too large (maximum ${maxBytes} bytes).`); } - return Buffer.concat(chunks, total).toString('utf8'); + return decodeUtf8(Buffer.concat(chunks, total), label); } finally { fs.closeSync(descriptor); } @@ -254,7 +270,7 @@ function initializeVault(options = {}) { return directory; }); }); - return { scopes, roots: { ...roots }, directories }; + return { scopes, roots, directories }; } function defaultMemoryId(now = new Date()) { @@ -646,6 +662,11 @@ function readMemoryById(id, options = {}) { .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) @@ -695,7 +716,7 @@ function doctorMemoryVault(options = {}) { } } } - const brokenLinks = allBrokenLinks + const brokenLinks = [...allBrokenLinks] .sort((left, right) => left.sourceId.localeCompare(right.sourceId)); const ok = loaded.invalidFileCount === 0 && allDuplicateIds.length === 0 @@ -726,15 +747,19 @@ function doctorMemoryVault(options = {}) { 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, diff --git a/scripts/memory-mcp.mjs b/scripts/memory-mcp.mjs index 769310429..cb1ea8e16 100755 --- a/scripts/memory-mcp.mjs +++ b/scripts/memory-mcp.mjs @@ -187,11 +187,6 @@ function assertScopesAuthorized(scopes, security) { return requestedScopes; } -function isTargetVisible(memory, harness) { - return memory.targetHarnesses.includes('all') - || memory.targetHarnesses.includes(harness); -} - function textResult(payload) { const text = JSON.stringify(payload, null, 2); if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) { @@ -307,7 +302,7 @@ function executeMemoryTool(name, rawArguments, options = {}) { }); return textResult({ memory: read.memory, - backlinks: read.backlinks.filter(memory => isTargetVisible(memory, security.harness)), + backlinks: read.backlinks, backlinksTruncated: read.backlinksTruncated, }); } diff --git a/scripts/memory.js b/scripts/memory.js index 804d44243..7fa25fe9d 100755 --- a/scripts/memory.js +++ b/scripts/memory.js @@ -6,6 +6,7 @@ const path = require('path'); const { MAX_BODY_BYTES, + decodeUtf8, doctorMemoryVault, initializeVault, readMemoryById, @@ -36,6 +37,9 @@ const BOOLEAN_OPTIONS = new Map([ ['--json', 'json'], ['--stdin', 'stdin'], ]); +const DEFAULT_STDIN_RETRY_DELAY_MS = 10; +const MAX_STDIN_RETRY_WAIT_MS = 5_000; +const STDIN_RETRY_SIGNAL = new Int32Array(new SharedArrayBuffer(4)); function usage() { return ` @@ -140,12 +144,42 @@ function oneValue(values, label, fallback = null) { return values[0]; } -function readBoundedStdin(maxBytes) { +function waitForStdinRetry(milliseconds) { + Atomics.wait(STDIN_RETRY_SIGNAL, 0, 0, milliseconds); +} + +function readBoundedStdin(maxBytes, retryOptions = {}) { + const retryDelayMs = Number.isInteger(retryOptions.retryDelayMs) + && retryOptions.retryDelayMs > 0 + ? retryOptions.retryDelayMs + : DEFAULT_STDIN_RETRY_DELAY_MS; + const maxRetryWaitMs = Number.isInteger(retryOptions.maxRetryWaitMs) + && retryOptions.maxRetryWaitMs >= 0 + ? retryOptions.maxRetryWaitMs + : MAX_STDIN_RETRY_WAIT_MS; + const wait = typeof retryOptions.wait === 'function' + ? retryOptions.wait + : waitForStdinRetry; const chunks = []; let total = 0; + let remainingRetryWaitMs = maxRetryWaitMs; while (total <= maxBytes) { const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total)); - const bytesRead = fs.readSync(0, buffer, 0, buffer.length, null); + let bytesRead; + try { + bytesRead = fs.readSync(0, buffer, 0, buffer.length, null); + } catch (error) { + const retryable = ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error?.code); + if (!retryable) throw error; + if (remainingRetryWaitMs < retryDelayMs) { + throw new Error( + `Standard input remained unavailable after ${maxRetryWaitMs}ms.` + ); + } + wait(retryDelayMs); + remainingRetryWaitMs -= retryDelayMs; + continue; + } if (bytesRead === 0) break; chunks.push(buffer.subarray(0, bytesRead)); total += bytesRead; @@ -153,7 +187,7 @@ function readBoundedStdin(maxBytes) { if (total > maxBytes) { throw new Error(`memory body is too large (maximum ${maxBytes} bytes).`); } - return Buffer.concat(chunks, total).toString('utf8'); + return decodeUtf8(Buffer.concat(chunks, total), 'memory body from standard input'); } function readBody(options) { diff --git a/skills/unified-memory/SKILL.md b/skills/unified-memory/SKILL.md index 0c6004715..2da486ffa 100644 --- a/skills/unified-memory/SKILL.md +++ b/skills/unified-memory/SKILL.md @@ -11,6 +11,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The vault stores portable `ecc.memory.v1` Markdown documents rather than harness-specific transcripts or inboxes. +## Runtime Prerequisite + +This skill is guidance, not the Memory Vault executable. Skill-only, minimal, +manual, and Claude plugin installs do not create the required commands on +`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI +or MCP examples: + +```bash +npm install -g ecc-universal +ecc memory --help +command -v ecc-memory-mcp +``` + +A repository checkout may instead run the CLI as +`node scripts/ecc.js memory ...`, but MCP configurations that name +`ecc-memory-mcp` still require that binary on `PATH`. + ## When To Use - Save durable context that another agent or later session will need. diff --git a/tests/ci/unified-memory-surface.test.js b/tests/ci/unified-memory-surface.test.js new file mode 100644 index 000000000..3c82fdb37 --- /dev/null +++ b/tests/ci/unified-memory-surface.test.js @@ -0,0 +1,69 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const SKILL_PATHS = [ + 'skills/unified-memory/SKILL.md', + '.agents/skills/unified-memory/SKILL.md', + '.cursor/skills/unified-memory/SKILL.md', +]; +const RUNTIME_DOC_PATHS = [ + ...SKILL_PATHS, + 'README.md', + 'docs/HERMES-SETUP.md', + 'README.zh-CN.md', + 'docs/zh-CN/README.md', +]; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed += 1; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` ${error.stack || error.message}`); + failed += 1; + } +} + +function read(relativePath) { + return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +function stripFrontmatter(source) { + return source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); +} + +console.log('\n=== Testing unified-memory install and adapter surfaces ===\n'); + +test('documents the separately installed ECC runtime on every exposed surface', () => { + for (const relativePath of RUNTIME_DOC_PATHS) { + const source = read(relativePath); + assert.match( + source, + /npm install -g ecc-universal/i, + `${relativePath} must state how to install the required CLI runtime` + ); + assert.match( + source, + /ecc-memory-mcp/, + `${relativePath} must identify the optional MCP binary` + ); + } +}); + +test('keeps harness-specific unified-memory skill bodies in sync', () => { + const bodies = SKILL_PATHS.map(relativePath => stripFrontmatter(read(relativePath))); + assert.strictEqual(bodies[1], bodies[0], `${SKILL_PATHS[1]} body drifted`); + assert.strictEqual(bodies[2], bodies[0], `${SKILL_PATHS[2]} body drifted`); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index 78fd324c2..84f4b186b 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -168,6 +168,27 @@ function runTests() { ); })) passed++; else failed++; + if (test('marks unified-memory install surfaces as requiring the separate ECC runtime', () => { + const component = getInstallComponent('skill:unified-memory'); + assert.deepStrictEqual(component.moduleIds, ['skill-unified-memory']); + assert.match(component.description, /ecc-universal/i); + assert.match(component.description, /separate|external|runtime/i); + + const modules = listInstallModules(); + const singleSkillModule = modules.find(module => module.id === 'skill-unified-memory'); + const workflowModule = modules.find(module => module.id === 'workflow-quality'); + assert.ok(singleSkillModule, 'Should define an explicit unified-memory module'); + assert.match(singleSkillModule.description, /ecc-universal/i); + assert.match(workflowModule.description, /ecc-universal/i); + + const plan = resolveInstallPlan({ + includeComponentIds: ['skill:unified-memory'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('skill-unified-memory')); + assert.ok(plan.selectedModuleIds.includes('platform-configs')); + })) passed++; else failed++; + if (test('lists supported legacy compatibility languages', () => { const languages = listLegacyCompatibilityLanguages(); assert.ok(languages.includes('typescript')); @@ -230,7 +251,14 @@ function runTests() { assert.deepStrictEqual( plan.selectedModuleIds, - ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + [ + 'rules-core', + 'agents-core', + 'commands-core', + 'platform-configs', + 'skill-unified-memory', + 'workflow-quality' + ] ); assert.ok(plan.skippedModuleIds.includes('hooks-runtime')); assert.ok(!plan.skippedModuleIds.includes('platform-configs')); @@ -248,7 +276,14 @@ function runTests() { assert.deepStrictEqual( plan.selectedModuleIds, - ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + [ + 'rules-core', + 'agents-core', + 'commands-core', + 'platform-configs', + 'skill-unified-memory', + 'workflow-quality' + ] ); assert.ok(!plan.selectedModuleIds.includes('hooks-runtime'), 'minimal profile should not install hooks-runtime'); @@ -265,7 +300,14 @@ function runTests() { assert.deepStrictEqual( plan.selectedModuleIds, - ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + [ + 'rules-core', + 'agents-core', + 'commands-core', + 'platform-configs', + 'skill-unified-memory', + 'workflow-quality' + ] ); assert.deepStrictEqual(plan.skippedModuleIds, []); assert.strictEqual(plan.targetAdapterId, 'qwen-home'); @@ -290,7 +332,14 @@ function runTests() { assert.deepStrictEqual( plan.selectedModuleIds, - ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + [ + 'rules-core', + 'agents-core', + 'commands-core', + 'platform-configs', + 'skill-unified-memory', + 'workflow-quality' + ] ); assert.deepStrictEqual(plan.skippedModuleIds, []); assert.strictEqual(plan.targetAdapterId, 'zed-project'); diff --git a/tests/lib/memory-schema.test.js b/tests/lib/memory-schema.test.js index 25e472bb5..088c937ab 100644 --- a/tests/lib/memory-schema.test.js +++ b/tests/lib/memory-schema.test.js @@ -103,5 +103,12 @@ test('rejects terminal and bidirectional control characters', () => { assertRejected(representativeMemory({ body: ' \n\t' }), 'pattern'); }); +test('accepts newlines, tabs, and carriage returns inside a non-empty Markdown body', () => { + const memory = representativeMemory({ + body: 'Line one\n\n- item\twith tab\r\nLine two', + }); + assert.strictEqual(validateMemory(memory), true, JSON.stringify(validateMemory.errors)); +}); + console.log(`\n${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/memory-vault.test.js b/tests/lib/memory-vault.test.js index 9aaaf0a99..1cd08a6bc 100644 --- a/tests/lib/memory-vault.test.js +++ b/tests/lib/memory-vault.test.js @@ -7,6 +7,9 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { + MAX_DIAGNOSTICS, + MAX_FILES, + MAX_SCAN_BYTES, MEMORY_SCHEMA_VERSION, MEMORY_KINDS, doctorMemoryVault, @@ -169,6 +172,21 @@ test('round-trips the strict ecc.memory.v1 Markdown frontmatter contract', () => assert.deepStrictEqual(parseMemoryDocument(serialized, 'handoff.md'), original); }); +test('accepts CRLF frontmatter delimiters and line endings', () => { + const original = baseMemory(); + const serialized = serializeMemoryDocument(original).replace(/\n/g, '\r\n'); + assert.deepStrictEqual(parseMemoryDocument(serialized, 'windows.md'), original); +}); + +test('requires the closing frontmatter marker to occupy an exact delimiter line', () => { + const malformed = serializeMemoryDocument(baseMemory()) + .replace('\n---\n\n', '\n---NOT-A-DELIMITER\n\n'); + assert.throws( + () => parseMemoryDocument(malformed, 'malformed-closing.md'), + /closing frontmatter|frontmatter line/i + ); +}); + test('rejects malformed, unknown-schema, and invalid metadata documents', () => { assert.throws(() => parseMemoryDocument('not frontmatter', 'bad.md'), /frontmatter/i); assert.throws( @@ -249,6 +267,30 @@ test('never overwrites a duplicate ID', () => { } }); +test('never follows a pre-existing destination symlink during create-only publication', () => { + const fixture = createFixture(); + const outside = path.join(fixture.root, 'outside.md'); + try { + const notes = path.join(fixture.roots.project, 'notes'); + fs.mkdirSync(notes, { recursive: true }); + fs.writeFileSync(outside, 'outside sentinel'); + const destination = path.join(notes, 'mem_20260726_01kexample.md'); + fs.symlinkSync(outside, destination); + + assert.throws( + () => saveMemory( + { title: 'Must not overwrite', body: 'create-only content' }, + fixedOptions(fixture.roots) + ), + /already exists|create-only|outside|refusing/i + ); + assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside sentinel'); + assert.strictEqual(fs.lstatSync(destination).isSymbolicLink(), true); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('fails closed when the project memory gitignore is preseeded with unsafe rules', () => { const fixture = createFixture(); try { @@ -327,6 +369,48 @@ test('rejects a vault path that traverses a symlink before creating directories' } }); +test('rejects a symlinked ancestor when roots come back from initializeVault', () => { + const fixture = createFixture(); + const outside = path.join(fixture.root, 'outside'); + fs.mkdirSync(outside); + try { + const initialized = initializeVault({ roots: fixture.roots, scopes: ['project'] }); + fs.rmSync(path.join(fixture.projectRoot, '.ecc'), { recursive: true, force: true }); + fs.symlinkSync(outside, path.join(fixture.projectRoot, '.ecc')); + + assert.throws( + () => saveMemory( + { title: 'Escaped note', body: 'must stay in the project' }, + { ...fixedOptions(fixture.roots), roots: initialized.roots } + ), + /symlink|outside|trusted/i + ); + assert.strictEqual(fs.existsSync(path.join(outside, 'memory')), false); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('fails closed when callers provide roots without a boundary policy', () => { + const fixture = createFixture(); + try { + const rootsWithoutPolicy = { + project: fixture.roots.project, + team: fixture.roots.team, + user: fixture.roots.user, + }; + assert.throws( + () => saveMemory( + { title: 'Untrusted roots', body: 'must not be written' }, + fixedOptions(rootsWithoutPolicy) + ), + /boundary policy|trusted boundary/i + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('rejects traversal IDs, oversized bodies, NUL bytes, and suspected secrets', () => { const fixture = createFixture(); try { @@ -449,6 +533,20 @@ test('opens regular text files without following a stable symlink', () => { } }); +test('rejects malformed UTF-8 instead of altering durable text', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-utf8-')); + const target = path.join(root, 'invalid.md'); + try { + fs.writeFileSync(target, Buffer.from([0x61, 0xc3, 0x28, 0x62])); + assert.throws( + () => readRegularTextFile(target, { maxBytes: 16 }), + /valid UTF-8/i + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('opens a file descriptor before inspecting path metadata', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-open-first-')); const target = path.join(root, 'target.md'); @@ -683,7 +781,7 @@ test('doctor caps traversal before an oversized directory can dominate recall', const notes = path.join(fixture.roots.project, 'notes'); try { fs.mkdirSync(notes, { recursive: true }); - for (let index = 0; index < 5001; index += 1) { + for (let index = 0; index < MAX_FILES + 1; index += 1) { fs.writeFileSync(path.join(notes, `noise-${index}.txt`), ''); } const report = doctorMemoryVault({ @@ -702,14 +800,16 @@ test('doctor caps hostile diagnostics and reports total counts', () => { const notes = path.join(fixture.roots.project, 'notes'); try { fs.mkdirSync(notes, { recursive: true }); - for (let index = 0; index < 120; index += 1) { + const invalidFileTotal = MAX_DIAGNOSTICS + 20; + for (let index = 0; index < invalidFileTotal; index += 1) { fs.writeFileSync(path.join(notes, `malformed-${index}.md`), 'not memory'); } const missingLinks = Array.from( { length: 64 }, (_, index) => `mem_missing_${String(index).padStart(3, '0')}` ); - for (let index = 0; index < 2; index += 1) { + const linkDocumentCount = Math.ceil((MAX_DIAGNOSTICS + 1) / missingLinks.length); + for (let index = 0; index < linkDocumentCount; index += 1) { fs.writeFileSync( path.join(notes, `links-${index}.md`), serializeMemoryDocument(baseMemory({ @@ -724,10 +824,10 @@ test('doctor caps hostile diagnostics and reports total counts', () => { roots: fixture.roots, scopes: ['project'], }); - assert.strictEqual(report.invalidFileCount, 120); - assert.strictEqual(report.invalidFiles.length, 100); - assert.strictEqual(report.brokenLinkCount, 128); - assert.strictEqual(report.brokenLinks.length, 100); + assert.strictEqual(report.invalidFileCount, invalidFileTotal); + assert.strictEqual(report.invalidFiles.length, MAX_DIAGNOSTICS); + assert.strictEqual(report.brokenLinkCount, linkDocumentCount * missingLinks.length); + assert.strictEqual(report.brokenLinks.length, MAX_DIAGNOSTICS); assert.strictEqual(report.diagnosticsTruncated, true); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); @@ -739,7 +839,9 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => { const notes = path.join(fixture.roots.project, 'notes'); try { fs.mkdirSync(notes, { recursive: true }); - for (let index = 0; index < 270; index += 1) { + const bodyBytes = 63 * 1024; + const fileTotal = Math.ceil(MAX_SCAN_BYTES / bodyBytes) + 2; + for (let index = 0; index < fileTotal; index += 1) { const id = `mem_scan_${String(index).padStart(4, '0')}`; fs.writeFileSync( path.join(notes, `${id}.md`), @@ -747,7 +849,7 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => { id, kind: 'note', links: [], - body: 'x'.repeat(63 * 1024), + body: 'x'.repeat(bodyBytes), })) ); } @@ -756,8 +858,8 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => { scopes: ['project'], }); assert.strictEqual(report.truncated, true); - assert.ok(report.scannedBytes <= 16 * 1024 * 1024); - assert.ok(report.memoryCount < 270); + assert.ok(report.scannedBytes <= MAX_SCAN_BYTES); + assert.ok(report.memoryCount < fileTotal); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 9cc17d903..9ef7def47 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -365,7 +365,10 @@ function runTests() { assert.ok(result.stdout.includes('Mode: manifest')); assert.ok(result.stdout.includes('Profile: core')); assert.ok(result.stdout.includes('Included components: (none)')); - assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality')); + assert.ok(result.stdout.includes( + 'Selected modules: rules-core, agents-core, commands-core, hooks-runtime, ' + + 'platform-configs, skill-unified-memory, workflow-quality' + )); assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'))); } finally { cleanup(homeDir); @@ -404,7 +407,10 @@ function runTests() { assert.strictEqual(result.code, 0, result.stderr); assert.ok(result.stdout.includes('Mode: manifest')); assert.ok(result.stdout.includes('Profile: minimal')); - assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, platform-configs, workflow-quality')); + assert.ok(result.stdout.includes( + 'Selected modules: rules-core, agents-core, commands-core, platform-configs, ' + + 'skill-unified-memory, workflow-quality' + )); assert.ok(!result.stdout.includes('hooks-runtime')); assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'))); } finally { @@ -491,7 +497,14 @@ function runTests() { assert.strictEqual(state.request.legacyMode, false); assert.deepStrictEqual( state.resolution.selectedModules, - ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + [ + 'rules-core', + 'agents-core', + 'commands-core', + 'platform-configs', + 'skill-unified-memory', + 'workflow-quality' + ] ); assert.ok(state.resolution.skippedModules.includes('hooks-runtime')); assert.ok(!state.resolution.skippedModules.includes('workflow-quality')); diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index 731ecdf42..2f540bf84 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -9,6 +9,11 @@ const { PassThrough } = require('stream'); const { pathToFileURL } = require('url'); const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs'); +const { + MAX_RESULTS, + resolveVaultRoots, + saveMemory, +} = require('../../scripts/lib/memory-vault'); let passed = 0; let failed = 0; @@ -345,6 +350,51 @@ async function main() { }); }); + await test('filters harness-visible backlinks before applying the response cap', async () => { + await withClient(async (client, fixture) => { + const roots = resolveVaultRoots({ + cwd: fixture.projectRoot, + env: fixture.env, + }); + const saveWithId = (input, id) => saveMemory(input, { + roots, + now: () => '2026-07-26T20:00:00.000Z', + idFactory: () => id, + }); + const targetId = 'mem_backlink_target'; + saveWithId({ + title: 'Backlink target', + body: 'Visible target body.', + targetHarnesses: ['claude'], + }, targetId); + + for (let index = 0; index < MAX_RESULTS; index += 1) { + saveWithId({ + title: `Hidden backlink ${index}`, + body: 'Only Hermes may see this backlink.', + targetHarnesses: ['hermes'], + links: [targetId], + }, `mem_backlink_hidden_${String(index).padStart(3, '0')}`); + } + saveWithId({ + title: 'Visible backlink', + body: 'Claude must still receive this backlink.', + targetHarnesses: ['claude'], + links: [targetId], + }, 'mem_backlink_visible_zzz'); + + const read = parseTextResult(await client.callTool({ + name: 'memory_read', + arguments: { id: targetId }, + })); + assert.deepStrictEqual( + read.backlinks.map(memory => memory.id), + ['mem_backlink_visible_zzz'] + ); + assert.strictEqual(read.backlinksTruncated, false); + }); + }); + await test('denies user scope unless the server explicitly grants it', async () => { await withClient(async client => { await assert.rejects( @@ -429,7 +479,8 @@ async function main() { env: fixture.env, encoding: 'utf8', }); - assert.notStrictEqual(started.status, 0); + assert.strictEqual(started.error, undefined); + assert.strictEqual(started.status, 1); assert.match(started.stderr, /ECC_MEMORY_HARNESS/); assert.ok(!started.stderr.includes('\n at ')); } finally { diff --git a/tests/scripts/memory.test.js b/tests/scripts/memory.test.js index 4a132a888..af1b4f193 100644 --- a/tests/scripts/memory.test.js +++ b/tests/scripts/memory.test.js @@ -8,7 +8,7 @@ const { spawnSync } = require('child_process'); const MEMORY_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'memory.js'); const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js'); -const { sanitizeTerminalText } = require(MEMORY_SCRIPT); +const { readBoundedStdin, sanitizeTerminalText } = require(MEMORY_SCRIPT); let passed = 0; let failed = 0; @@ -103,6 +103,68 @@ test('routes stdin through ecc memory without dropping the body', () => { } }); +test('retries transient stdin EAGAIN without busy-spinning and preserves byte bounds', () => { + const originalReadSync = fs.readSync; + let readCalls = 0; + let waitCalls = 0; + try { + fs.readSync = (_descriptor, buffer) => { + readCalls += 1; + if (readCalls <= 2) { + const error = new Error('temporarily unavailable'); + error.code = 'EAGAIN'; + throw error; + } + if (readCalls === 3) { + buffer.write('ready'); + return 5; + } + return 0; + }; + + assert.strictEqual(readBoundedStdin(8, { + retryDelayMs: 1, + maxRetryWaitMs: 4, + wait: () => { + waitCalls += 1; + }, + }), 'ready'); + assert.strictEqual(readCalls, 4); + assert.strictEqual(waitCalls, 2); + } finally { + fs.readSync = originalReadSync; + } +}); + +test('bounds persistent stdin EAGAIN retries instead of waiting forever', () => { + const originalReadSync = fs.readSync; + let readCalls = 0; + let waitCalls = 0; + try { + fs.readSync = () => { + readCalls += 1; + const error = new Error('temporarily unavailable'); + error.code = 'EAGAIN'; + throw error; + }; + + assert.throws( + () => readBoundedStdin(8, { + retryDelayMs: 1, + maxRetryWaitMs: 3, + wait: () => { + waitCalls += 1; + }, + }), + /standard input remained unavailable/i + ); + assert.strictEqual(readCalls, 4); + assert.strictEqual(waitCalls, 3); + } finally { + fs.readSync = originalReadSync; + } +}); + test('initializes selected scopes and reports their roots as JSON', () => { const fixture = createFixture(); try { @@ -310,6 +372,20 @@ test('rejects ambiguous body sources and does not expose a trust promotion flag' ], fixture, { input: ' \n\t' }); assert.notStrictEqual(empty.status, 0); assert.ok(empty.stderr.includes('non-whitespace context')); + + const invalidUtf8Body = path.join(fixture.root, 'invalid-utf8.md'); + fs.writeFileSync(invalidUtf8Body, Buffer.from([0x61, 0xc3, 0x28, 0x62])); + const invalidUtf8 = run(MEMORY_SCRIPT, [ + 'save', + '--title', 'Invalid UTF-8', + '--body-file', invalidUtf8Body, + ], fixture); + assert.notStrictEqual(invalidUtf8.status, 0); + assert.match(invalidUtf8.stderr, /valid UTF-8/i); + assert.strictEqual( + fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')), + false + ); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }