mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
feat: Cursor-independent ECC memory via ECC_AGENT_DATA_HOME (#2066)
* feat: auto-isolate ECC memory data for Cursor via ECC_AGENT_DATA_HOME Add ECC_AGENT_DATA_HOME (defaults to ~/.claude) with Cursor-aware resolution, sessionStart env injection, install scaffolds, and hook bootstrap so memory hooks do not collide with Claude Code when both harnesses are used. Closes #2065 Co-authored-by: Cursor <cursoragent@cursor.com> * fix: log agent-data config errors and ship cursor sessionStart deps Address CodeRabbit review: log invalid .cursor/ecc-agent-data.json parse failures, and copy cursor-session-env.js plus lib deps on legacy Cursor install so sessionStart hook path exists without hooks-runtime alone. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resolve relative agentDataHome paths from project root Project config values like ".ecc-data" now resolve against the repository root (parent of .cursor/), not process.cwd(), so Cursor hooks persist memory in the intended directory regardless of hook cwd. Addresses cubic review on PR #2066. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: explain getHomeDir duplicate and docstring policy Document why agent-data-home keeps a local home-dir helper (circular require with utils.js) and list consolidation options for maintainers. Note that CodeRabbit JSDoc coverage warnings are informational relative to ECC's usual script documentation style. Addresses cubic P2 context on PR #2066. Co-authored-by: Cursor <cursoragent@cursor.com> * test: isolate agent-data-home tests from dogfooded .cursor config Use isolated temp cwd for default-resolution cases and assert resolveAgentDataHome({ projectDir }) reads ecc-agent-data.json. Document cwd/project caveats in the test file header. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
GitHub
parent
81c9150512
commit
6a40469408
50
scripts/hooks/cursor-session-env.js
Normal file
50
scripts/hooks/cursor-session-env.js
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cursor sessionStart hook — inject ECC_AGENT_DATA_HOME for the composer session.
|
||||
*
|
||||
* Cursor passes session-scoped env from sessionStart output to all later hooks.
|
||||
* @see https://cursor.com/docs/hooks
|
||||
*/
|
||||
|
||||
const {
|
||||
getCursorSessionEnvPayload,
|
||||
resolveAgentDataHome,
|
||||
AGENT_DATA_HOME_ENV,
|
||||
} = require('../lib/agent-data-home');
|
||||
const { readStdinJson, log } = require('../lib/utils');
|
||||
|
||||
function main() {
|
||||
readStdinJson()
|
||||
.then(() => {
|
||||
const envPayload = getCursorSessionEnvPayload({ preferCursorDefault: true });
|
||||
const agentDataHome = envPayload[AGENT_DATA_HOME_ENV];
|
||||
const payload = {
|
||||
env: envPayload,
|
||||
additional_context: [
|
||||
'ECC memory persistence uses a dedicated agent data root for this Cursor session.',
|
||||
`${AGENT_DATA_HOME_ENV}=${agentDataHome}`,
|
||||
'Session summaries, learned skills, aliases, and metrics live under that directory.',
|
||||
'Override via shell env, project .cursor/ecc-agent-data.json, or ECC docs (issue #2065).',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
log(`[cursor-session-env] Set ${AGENT_DATA_HOME_ENV}=${agentDataHome}`);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
const fallbackHome = resolveAgentDataHome({ preferCursorDefault: true });
|
||||
const payload = {
|
||||
env: { [AGENT_DATA_HOME_ENV]: fallbackHome },
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
log(`[cursor-session-env] Fallback ${AGENT_DATA_HOME_ENV}=${fallbackHome} (${error.message})`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -4,6 +4,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home');
|
||||
|
||||
function readStdinRaw() {
|
||||
try {
|
||||
@@ -83,14 +84,16 @@ function findShellBinary() {
|
||||
}
|
||||
|
||||
function spawnNode(rootDir, relPath, raw, args) {
|
||||
ensureAgentDataHomeEnv();
|
||||
const hookEnv = {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
};
|
||||
return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
},
|
||||
env: hookEnv,
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
@@ -107,14 +110,16 @@ function spawnShell(rootDir, relPath, raw, args) {
|
||||
};
|
||||
}
|
||||
|
||||
ensureAgentDataHomeEnv();
|
||||
const hookEnv = {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
};
|
||||
return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
},
|
||||
env: hookEnv,
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
|
||||
199
scripts/lib/agent-data-home.js
Normal file
199
scripts/lib/agent-data-home.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Resolve ECC agent data home (memory persistence root) across harnesses.
|
||||
*
|
||||
* Docstring policy: public entry points here are documented; small internal
|
||||
* helpers (e.g. `expandHomePath`, `readProjectConfigAt`) are left undocumented on
|
||||
* purpose, consistent with ECC script modules elsewhere. Automated PR reviewers
|
||||
* (e.g. CodeRabbit) may still flag low JSDoc coverage against a high threshold on
|
||||
* the diff—that check is informational for this repo, not a bar every helper in
|
||||
* touched files must meet. Prefer clarity in code and tests over blanket JSDoc on
|
||||
* private helpers unless maintainers adopt a project-wide coverage rule.
|
||||
*
|
||||
* @see https://github.com/affaan-m/ECC/issues/2065
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
|
||||
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
|
||||
const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc'];
|
||||
const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json');
|
||||
|
||||
/**
|
||||
* Home directory for tilde expansion and default agent-data paths.
|
||||
*
|
||||
* Intentionally mirrors `getHomeDir()` in `scripts/lib/utils.js` (HOME/USERPROFILE,
|
||||
* then `os.homedir()`). Do not import `utils.getHomeDir` here: `utils.js` already
|
||||
* requires this module (`resolveAgentDataHome`), which would create a circular
|
||||
* dependency and risk divergent defaults for `~/.cursor/ecc` vs `~/.claude`.
|
||||
*
|
||||
* If consolidation is needed later, prefer one of:
|
||||
*
|
||||
* | Approach | Tradeoff |
|
||||
* | --- | --- |
|
||||
* | Shared `scripts/lib/home-dir.js` imported by both | Clean; breaks the cycle |
|
||||
* | Keep duplicate + cross-reference comment (this file) | Zero require risk |
|
||||
* | Move all resolution here; thin-wrap from `utils` | Larger refactor |
|
||||
*/
|
||||
function getHomeDirFromEnv() {
|
||||
const explicitHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (explicitHome && String(explicitHome).trim().length > 0) {
|
||||
return path.resolve(explicitHome);
|
||||
}
|
||||
return require('os').homedir();
|
||||
}
|
||||
|
||||
function expandHomePath(value, baseDir) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(getHomeDirFromEnv(), remainder) : getHomeDirFromEnv();
|
||||
}
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
const base = baseDir && String(baseDir).trim()
|
||||
? path.resolve(baseDir)
|
||||
: process.cwd();
|
||||
return path.resolve(base, trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root for a config file under .cursor/ecc-agent-data.json.
|
||||
*/
|
||||
function resolveProjectRootFromConfigPath(configPath) {
|
||||
const configDir = path.dirname(path.resolve(configPath));
|
||||
if (path.basename(configDir) === '.cursor') {
|
||||
return path.dirname(configDir);
|
||||
}
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current process is a Cursor hook subprocess.
|
||||
* Cursor documents CURSOR_VERSION and CURSOR_PROJECT_DIR for hook scripts.
|
||||
*/
|
||||
function isCursorHookRuntime() {
|
||||
if (process.env.CURSOR_VERSION && String(process.env.CURSOR_VERSION).trim()) {
|
||||
return true;
|
||||
}
|
||||
if (process.env.CURSOR_PROJECT_DIR && String(process.env.CURSOR_PROJECT_DIR).trim()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDefaultCursorAgentDataHome() {
|
||||
return path.join(getHomeDirFromEnv(), ...DEFAULT_CURSOR_ECC_DIR_SEGMENTS);
|
||||
}
|
||||
|
||||
function getDefaultClaudeAgentDataHome() {
|
||||
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
|
||||
}
|
||||
|
||||
function readProjectConfigAt(configPath) {
|
||||
if (!configPath || typeof configPath !== 'string') return null;
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return null;
|
||||
const projectRoot = resolveProjectRootFromConfigPath(configPath);
|
||||
return expandHomePath(candidate, projectRoot);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readProjectConfig(projectDir) {
|
||||
if (!projectDir || typeof projectDir !== 'string') return null;
|
||||
return readProjectConfigAt(path.join(path.resolve(projectDir), PROJECT_CONFIG_RELATIVE));
|
||||
}
|
||||
|
||||
function resolveProjectDir() {
|
||||
const candidates = [
|
||||
process.env.CURSOR_PROJECT_DIR,
|
||||
process.env.CLAUDE_PROJECT_DIR,
|
||||
process.cwd(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== 'string') continue;
|
||||
const resolved = path.resolve(candidate);
|
||||
if (fs.existsSync(path.join(resolved, '.cursor'))) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve agent data home without mutating process.env.
|
||||
*/
|
||||
function resolveAgentDataHome(options = {}) {
|
||||
const fromEnv = expandHomePath(process.env[AGENT_DATA_HOME_ENV]);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const projectDir = options.projectDir || resolveProjectDir();
|
||||
const fromProject = readProjectConfig(projectDir);
|
||||
if (fromProject) return fromProject;
|
||||
|
||||
if (options.preferCursorDefault === true || isCursorHookRuntime()) {
|
||||
return getDefaultCursorAgentDataHome();
|
||||
}
|
||||
|
||||
return getDefaultClaudeAgentDataHome();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set ECC_AGENT_DATA_HOME on the current process when unset (hook subprocess safety net).
|
||||
* @returns {string} Resolved agent data home
|
||||
*/
|
||||
function ensureAgentDataHomeEnv(options = {}) {
|
||||
const resolved = resolveAgentDataHome(options);
|
||||
if (!expandHomePath(process.env[AGENT_DATA_HOME_ENV])) {
|
||||
process.env[AGENT_DATA_HOME_ENV] = resolved;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Cursor sessionStart hook output env payload.
|
||||
*/
|
||||
function getCursorSessionEnvPayload(options = {}) {
|
||||
const agentDataHome = resolveAgentDataHome({
|
||||
...options,
|
||||
preferCursorDefault: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ECC_AGENT_DATA_HOME: agentDataHome,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AGENT_DATA_HOME_ENV,
|
||||
DEFAULT_CLAUDE_DIR_NAME,
|
||||
DEFAULT_CURSOR_ECC_DIR_SEGMENTS,
|
||||
PROJECT_CONFIG_RELATIVE,
|
||||
expandHomePath,
|
||||
resolveProjectRootFromConfigPath,
|
||||
isCursorHookRuntime,
|
||||
getDefaultCursorAgentDataHome,
|
||||
getDefaultClaudeAgentDataHome,
|
||||
readProjectConfig,
|
||||
readProjectConfigAt,
|
||||
resolveProjectDir,
|
||||
resolveAgentDataHome,
|
||||
ensureAgentDataHomeEnv,
|
||||
getCursorSessionEnvPayload,
|
||||
};
|
||||
@@ -207,6 +207,52 @@ function readJsonObject(filePath, label) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function addCursorAgentDataScaffoldOperations(operations, options) {
|
||||
const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor');
|
||||
if (!fs.existsSync(scaffoldRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
|
||||
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
|
||||
strategy: 'preserve-relative-path',
|
||||
});
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
|
||||
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
|
||||
strategy: 'preserve-relative-path',
|
||||
});
|
||||
|
||||
addJsonMergeOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
|
||||
destinationPath: path.join(options.targetRoot, 'hooks.json'),
|
||||
});
|
||||
|
||||
const cursorSessionHookDeps = [
|
||||
path.join('scripts', 'hooks', 'cursor-session-env.js'),
|
||||
path.join('scripts', 'lib', 'agent-data-home.js'),
|
||||
path.join('scripts', 'lib', 'utils.js'),
|
||||
];
|
||||
|
||||
for (const sourceRelativePath of cursorSessionHookDeps) {
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(options.targetRoot, sourceRelativePath),
|
||||
strategy: 'preserve-relative-path',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addJsonMergeOperation(operations, options) {
|
||||
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
@@ -405,6 +451,12 @@ function planCursorLegacyInstall(context) {
|
||||
destinationPath: path.join(targetRoot, 'mcp.json'),
|
||||
});
|
||||
|
||||
addCursorAgentDataScaffoldOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
targetRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
|
||||
2
scripts/lib/session-aliases.d.ts
vendored
2
scripts/lib/session-aliases.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Session Aliases Library for Claude Code.
|
||||
* Manages named aliases for session files, stored in ~/.claude/session-aliases.json.
|
||||
* Manages named aliases for session files, stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude).
|
||||
*/
|
||||
|
||||
/** Internal alias storage entry */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Session Aliases Library for Claude Code
|
||||
* Manages session aliases stored in ~/.claude/session-aliases.json
|
||||
* Manages session aliases stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
14
scripts/lib/utils.d.ts
vendored
14
scripts/lib/utils.d.ts
vendored
@@ -15,19 +15,25 @@ export const isLinux: boolean;
|
||||
/** Get the user's home directory (cross-platform) */
|
||||
export function getHomeDir(): string;
|
||||
|
||||
/** Get the Claude config directory (~/.claude) */
|
||||
/**
|
||||
* ECC agent data root for memory persistence and related state.
|
||||
* Defaults to ~/.claude; override with ECC_AGENT_DATA_HOME (e.g. ~/.cursor/ecc).
|
||||
*/
|
||||
export function getAgentDataHome(): string;
|
||||
|
||||
/** Get the agent data directory (alias of getAgentDataHome) */
|
||||
export function getClaudeDir(): string;
|
||||
|
||||
/** Get the canonical ECC sessions directory (~/.claude/session-data) */
|
||||
/** Get the canonical ECC sessions directory ($ECC_AGENT_DATA_HOME/session-data) */
|
||||
export function getSessionsDir(): string;
|
||||
|
||||
/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */
|
||||
/** Get the legacy sessions directory ($ECC_AGENT_DATA_HOME/sessions) */
|
||||
export function getLegacySessionsDir(): string;
|
||||
|
||||
/** Get session directories to search, with canonical storage first and legacy fallback second */
|
||||
export function getSessionSearchDirs(): string[];
|
||||
|
||||
/** Get the learned skills directory (~/.claude/skills/learned) */
|
||||
/** Get the learned skills directory ($ECC_AGENT_DATA_HOME/skills/learned) */
|
||||
export function getLearnedSkillsDir(): string;
|
||||
|
||||
/** Get the temp directory (cross-platform) */
|
||||
|
||||
@@ -15,6 +15,9 @@ const isMacOS = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
const SESSION_DATA_DIR_NAME = 'session-data';
|
||||
const LEGACY_SESSIONS_DIR_NAME = 'sessions';
|
||||
const {
|
||||
resolveAgentDataHome,
|
||||
} = require('./agent-data-home');
|
||||
const WINDOWS_RESERVED_SESSION_IDS = new Set([
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
@@ -33,12 +36,20 @@ function getHomeDir() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Claude config directory
|
||||
* ECC agent data root for memory persistence (see scripts/lib/agent-data-home.js).
|
||||
*/
|
||||
function getAgentDataHome() {
|
||||
return resolveAgentDataHome();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Claude config directory (alias of getAgentDataHome for backwards compatibility).
|
||||
*/
|
||||
function getClaudeDir() {
|
||||
return path.join(getHomeDir(), '.claude');
|
||||
return getAgentDataHome();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the sessions directory
|
||||
*/
|
||||
@@ -585,6 +596,7 @@ module.exports = {
|
||||
|
||||
// Directories
|
||||
getHomeDir,
|
||||
getAgentDataHome,
|
||||
getClaudeDir,
|
||||
getSessionsDir,
|
||||
getLegacySessionsDir,
|
||||
|
||||
Reference in New Issue
Block a user