mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
chore: integrate current main into hardening
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { normalizeAgentTools } = require('./agent-tools');
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from a markdown string.
|
||||
@@ -35,6 +36,10 @@ function parseFrontmatter(content) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key === 'tools') {
|
||||
value = normalizeAgentTools(value);
|
||||
}
|
||||
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
function stripSurroundingQuotes(value) {
|
||||
const trimmed = value.trim();
|
||||
const quote = trimmed[0];
|
||||
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function splitTopLevelToolList(value) {
|
||||
const items = [];
|
||||
const delimiters = [];
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let itemStart = 0;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === '\\') {
|
||||
escaped = true;
|
||||
} else if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(' || character === '[' || character === '{') {
|
||||
delimiters.push(character);
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedOpener = {
|
||||
')': '(',
|
||||
']': '[',
|
||||
'}': '{',
|
||||
}[character];
|
||||
if (expectedOpener && delimiters.at(-1) === expectedOpener) {
|
||||
delimiters.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ',' && delimiters.length === 0) {
|
||||
items.push(value.slice(itemStart, index));
|
||||
itemStart = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
items.push(value.slice(itemStart));
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Claude agent frontmatter tools to the array shape used internally.
|
||||
*
|
||||
* Claude Code expects tools to be a comma-separated scalar. Flow sequences are
|
||||
* still accepted here so ECC can read legacy or harness-adapted agent files.
|
||||
*/
|
||||
function normalizeAgentTools(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(item => typeof item === 'string')
|
||||
.map(stripSurroundingQuotes)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const listValue = trimmed.startsWith('[') && trimmed.endsWith(']')
|
||||
? trimmed.slice(1, -1)
|
||||
: stripSurroundingQuotes(trimmed);
|
||||
|
||||
if (!listValue.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return splitTopLevelToolList(listValue)
|
||||
.map(stripSurroundingQuotes)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeAgentTools,
|
||||
};
|
||||
@@ -123,6 +123,11 @@ function applyInstallPlan(plan) {
|
||||
return applyPlan(plan);
|
||||
}
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
const { previewInstallPlan: previewPlan } = require('./install/apply');
|
||||
return previewPlan(plan);
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
@@ -802,6 +807,7 @@ module.exports = {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
applyInstallPlan,
|
||||
previewInstallPlan,
|
||||
createLegacyCompatInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
|
||||
@@ -7,6 +7,9 @@ const { resolveInstallPlan, loadInstallManifests } = require('./install-manifest
|
||||
const { readInstallState, validateInstallState } = require('./install-state');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { createManifestInstallPlan } = require('./install-executor');
|
||||
const {
|
||||
prepareClaudeSkillMigration,
|
||||
} = require('./install/claude-skill-migration');
|
||||
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
|
||||
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
|
||||
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
|
||||
@@ -1383,6 +1386,30 @@ function writeRefreshedInstallState(record, statePreview) {
|
||||
);
|
||||
}
|
||||
|
||||
function prepareRepairMigration(plan, record) {
|
||||
const trustedPlan = {
|
||||
...plan,
|
||||
adapter: record.adapter,
|
||||
targetRoot: record.targetRoot,
|
||||
installRoot: record.targetRoot,
|
||||
installStatePath: record.installStatePath,
|
||||
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
|
||||
};
|
||||
const migration = prepareClaudeSkillMigration(trustedPlan);
|
||||
return {
|
||||
migration,
|
||||
plan: {
|
||||
...trustedPlan,
|
||||
operations: migration.finalState.operations,
|
||||
statePreview: migration.finalState,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function repairInstalledStates(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
@@ -1420,9 +1447,10 @@ function repairInstalledStates(options = {}) {
|
||||
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
||||
|
||||
if (needsOpencodeBuild && options.dryRun) {
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context, {
|
||||
const rawPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
@@ -1445,6 +1473,7 @@ function repairInstalledStates(options = {}) {
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
@@ -1464,7 +1493,11 @@ function repairInstalledStates(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context);
|
||||
const rawPlan = createRepairPlanFromRecord(record, context);
|
||||
const {
|
||||
migration,
|
||||
plan: desiredPlan,
|
||||
} = prepareRepairMigration(rawPlan, record);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
@@ -1486,14 +1519,20 @@ function repairInstalledStates(options = {}) {
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
warnings: desiredPlan.warnings,
|
||||
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = needsOpencodeBuild
|
||||
? [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]
|
||||
: repairOperations.map(operation => operation.destinationPath);
|
||||
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
||||
operation => operation.destinationPath
|
||||
);
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...repairOperations.map(operation => operation.destinationPath),
|
||||
...legacyMigrationPaths,
|
||||
])];
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
@@ -1503,11 +1542,17 @@ function repairInstalledStates(options = {}) {
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: plannedRepairs.length === 0,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
|
||||
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
|
||||
writeRefreshedInstallState(record, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of repairOperations) {
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
@@ -1518,15 +1563,31 @@ function repairInstalledStates(options = {}) {
|
||||
repairedPaths.push(repairedPath);
|
||||
}
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
for (const operation of migration.legacyOperationsToRemove) {
|
||||
const removedPath = removeContainedPath(
|
||||
operation.destinationPath,
|
||||
record.targetRoot,
|
||||
'migrate managed Claude skill',
|
||||
{ force: true }
|
||||
);
|
||||
if (removedPath) {
|
||||
repairedPaths.push(removedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeRefreshedInstallState(record, desiredPlan.statePreview);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok',
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
|
||||
? 'repaired'
|
||||
: 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'skills') {
|
||||
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
|
||||
return path.join(targetRoot, 'skills');
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('skills/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('skills/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'skills') {
|
||||
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
|
||||
return path.join(targetRoot, 'skills');
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('skills/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('skills/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ const path = require('path');
|
||||
|
||||
const { writeInstallState } = require('../install-state');
|
||||
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
||||
const { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const {
|
||||
assertSafeClaudeSkillOperation,
|
||||
prepareClaudeSkillMigration,
|
||||
removeLegacyClaudeSkillFiles,
|
||||
} = require('./claude-skill-migration');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
|
||||
function isMarkdownPath(filePath) {
|
||||
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
|
||||
@@ -139,13 +144,49 @@ function buildResolvedClaudeHooks(plan) {
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
|
||||
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
|
||||
const linkIndex = buildLinkIndexForPlan(plan);
|
||||
function previewInstallPlan(plan) {
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
return {
|
||||
...plan,
|
||||
statePreview: migration.finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
],
|
||||
applied: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const operation of plan.operations) {
|
||||
function applyInstallPlan(plan, dependencies = {}) {
|
||||
const persistInstallState = dependencies.writeInstallState || writeInstallState;
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
operations: migration.appliedOperations,
|
||||
};
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
|
||||
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
|
||||
const linkIndex = buildLinkIndexForPlan(appliedPlan);
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
|
||||
if (migration.requiresBridgeState) {
|
||||
// Own every operation that may be written during a flat-skill migration
|
||||
// before the first copy. A later failure is retryable and uninstall can
|
||||
// clean the entire partial install, including non-skill files. During
|
||||
// legacy migration the bridge also retains the prior managed operations.
|
||||
persistInstallState(plan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of appliedPlan.operations) {
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
// Recheck directories that were absent during the first validation. This
|
||||
// narrows the symlink-swap window around mkdirSync, but path checks cannot
|
||||
// eliminate a later TOCTOU race before the file write.
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
const payload = cloneJsonValue(operation.mergePayload);
|
||||
@@ -174,16 +215,14 @@ function applyInstallPlan(plan) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Namespaced markdown (e.g. skills/<id> -> skills/ecc/<id>) needs its
|
||||
// relative cross-directory links rewritten so they resolve after install
|
||||
// (issue #2340). Files whose install path is unchanged (no namespace
|
||||
// injected) and all non-markdown files stay on the byte-for-byte copy path.
|
||||
// Markdown may reference files whose installed paths move, such as rules
|
||||
// copied under rules/ecc. Rewrite only links that point at installed targets;
|
||||
// untouched links and non-markdown files stay on the byte-for-byte path.
|
||||
if (
|
||||
linkIndex
|
||||
&& operation.kind === 'copy-file'
|
||||
&& operation.sourceRelativePath
|
||||
&& isMarkdownPath(operation.destinationPath)
|
||||
&& isNamespacedSource(operation.sourceRelativePath, linkIndex)
|
||||
) {
|
||||
const rewritten = rewriteRelativeLinks(
|
||||
fs.readFileSync(operation.sourcePath, 'utf8'),
|
||||
@@ -205,14 +244,26 @@ function applyInstallPlan(plan) {
|
||||
);
|
||||
}
|
||||
|
||||
writeInstallState(plan.installStatePath, plan.statePreview);
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
|
||||
}
|
||||
persistInstallState(plan.installStatePath, migration.finalState);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: migration.finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyInstallPlan,
|
||||
previewInstallPlan,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
|
||||
const CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourceRelativePath(sourceRelativePath) {
|
||||
const slashNormalized = String(sourceRelativePath || '').replace(/\\/g, '/');
|
||||
const normalized = path.posix.normalize(slashNormalized).replace(/^\.\//, '');
|
||||
if (
|
||||
!normalized
|
||||
|| normalized === '.'
|
||||
|| normalized === '..'
|
||||
|| normalized.startsWith('../')
|
||||
|| path.posix.isAbsolute(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function samePath(leftPath, rightPath) {
|
||||
return comparablePath(leftPath) === comparablePath(rightPath);
|
||||
}
|
||||
|
||||
function assertSafeSkillPath(targetPath, targetRoot, action) {
|
||||
const resolvedRoot = path.resolve(targetRoot);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
const relativePath = path.relative(resolvedRoot, resolvedTarget);
|
||||
if (
|
||||
relativePath === ''
|
||||
|| relativePath.startsWith('..')
|
||||
|| path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
|
||||
);
|
||||
}
|
||||
|
||||
let currentPath = resolvedRoot;
|
||||
for (const segment of relativePath.split(path.sep)) {
|
||||
currentPath = path.join(currentPath, segment);
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.lstatSync(currentPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathExists(targetRoot)) {
|
||||
assertWithinTrustedRoot(targetPath, targetRoot, action);
|
||||
}
|
||||
}
|
||||
|
||||
function describeClaudeSkillOperation(targetRoot, operation) {
|
||||
if (!operation || operation.kind !== 'copy-file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
|
||||
if (!sourceRelativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceParts = sourceRelativePath.split('/');
|
||||
if (sourceParts[0] !== 'skills' || sourceParts.length < 3 || !sourceParts[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillName = sourceParts[1];
|
||||
const relativeParts = sourceParts.slice(2);
|
||||
const flatSkillRoot = path.join(targetRoot, 'skills', skillName);
|
||||
const legacySkillRoot = path.join(targetRoot, 'skills', 'ecc', skillName);
|
||||
|
||||
return {
|
||||
sourceKey: sourceRelativePath,
|
||||
skillName,
|
||||
flatSkillRoot,
|
||||
flatDestinationPath: path.join(flatSkillRoot, ...relativeParts),
|
||||
legacySkillRoot,
|
||||
legacyDestinationPath: path.join(legacySkillRoot, ...relativeParts),
|
||||
};
|
||||
}
|
||||
|
||||
function assertSafeClaudeSkillOperation(plan, operation) {
|
||||
const target = plan && plan.adapter && plan.adapter.target;
|
||||
if (!CLAUDE_TARGETS.has(target)) {
|
||||
return;
|
||||
}
|
||||
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
||||
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
||||
return;
|
||||
}
|
||||
assertSafeSkillPath(
|
||||
operation.destinationPath,
|
||||
plan.targetRoot,
|
||||
'install Claude skill'
|
||||
);
|
||||
}
|
||||
|
||||
function isManagedOperation(operation) {
|
||||
return operation && operation.ownership === 'managed';
|
||||
}
|
||||
|
||||
function uniqueOperations(operations) {
|
||||
const seen = new Set();
|
||||
return operations.filter(operation => {
|
||||
const key = [
|
||||
operation.kind,
|
||||
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
|
||||
comparablePath(operation.destinationPath),
|
||||
].join('\0');
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildState(statePreview, operations) {
|
||||
return {
|
||||
...statePreview,
|
||||
operations: uniqueOperations(operations).map(operation => ({ ...operation })),
|
||||
};
|
||||
}
|
||||
|
||||
function groupCurrentSkillOperations(plan) {
|
||||
const groups = new Map();
|
||||
for (const operation of plan.operations) {
|
||||
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
||||
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertSafeSkillPath(
|
||||
operation.destinationPath,
|
||||
plan.targetRoot,
|
||||
'install Claude skill'
|
||||
);
|
||||
|
||||
const current = groups.get(descriptor.flatSkillRoot) || [];
|
||||
current.push({ operation, descriptor });
|
||||
groups.set(descriptor.flatSkillRoot, current);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function classifyPreviousOperations(plan, previousState) {
|
||||
const flatByDestination = new Map();
|
||||
const legacyBySource = new Map();
|
||||
const legacyBySkillRoot = new Map();
|
||||
|
||||
for (const operation of (previousState && previousState.operations) || []) {
|
||||
if (!isManagedOperation(operation)) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
||||
if (!descriptor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
||||
assertSafeSkillPath(
|
||||
operation.destinationPath,
|
||||
plan.targetRoot,
|
||||
'inspect managed Claude skill'
|
||||
);
|
||||
flatByDestination.set(comparablePath(operation.destinationPath), operation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!samePath(operation.destinationPath, descriptor.legacyDestinationPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertSafeSkillPath(
|
||||
operation.destinationPath,
|
||||
plan.targetRoot,
|
||||
'migrate managed Claude skill'
|
||||
);
|
||||
legacyBySource.set(descriptor.sourceKey, operation);
|
||||
const current = legacyBySkillRoot.get(descriptor.legacySkillRoot) || [];
|
||||
current.push({ operation, descriptor });
|
||||
legacyBySkillRoot.set(descriptor.legacySkillRoot, current);
|
||||
}
|
||||
|
||||
return {
|
||||
flatByDestination,
|
||||
legacyBySource,
|
||||
legacyBySkillRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function createConflictWarning(skillName, flatSkillRoot, retainsLegacy) {
|
||||
const legacySuffix = retainsLegacy
|
||||
? ' The existing ECC-managed nested copy was retained and remains tracked for uninstall.'
|
||||
: '';
|
||||
return `Skipped Claude skill '${skillName}' at ${flatSkillRoot}: the flat skill directory is user-owned because it is not recorded in ECC install-state.${legacySuffix}`;
|
||||
}
|
||||
|
||||
function createFileConflictWarning(destinationPath, retainsLegacy) {
|
||||
const legacySuffix = retainsLegacy
|
||||
? ' The matching ECC-managed nested file was retained and remains tracked for uninstall.'
|
||||
: '';
|
||||
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
|
||||
}
|
||||
|
||||
function createDisabledMigration(plan) {
|
||||
return {
|
||||
enabled: false,
|
||||
appliedOperations: [...plan.operations],
|
||||
skippedOperations: [],
|
||||
warnings: [],
|
||||
bridgeState: plan.statePreview,
|
||||
finalState: plan.statePreview,
|
||||
legacyOperationsToRemove: [],
|
||||
requiresBridgeState: false,
|
||||
};
|
||||
}
|
||||
|
||||
function collectRetainedLegacyOperations(currentGroups, previous) {
|
||||
const currentSourceKeys = new Set(
|
||||
[...currentGroups.values()]
|
||||
.flat()
|
||||
.map(({ descriptor }) => descriptor.sourceKey)
|
||||
);
|
||||
return (
|
||||
[...previous.legacyBySource.entries()]
|
||||
.filter(([sourceKey]) => !currentSourceKeys.has(sourceKey))
|
||||
.map(([_sourceKey, operation]) => operation)
|
||||
);
|
||||
}
|
||||
|
||||
function classifySkillGroup(flatSkillRoot, entries, previous) {
|
||||
const hasManagedFlatFile = entries.some(({ operation }) => (
|
||||
previous.flatByDestination.has(comparablePath(operation.destinationPath))
|
||||
));
|
||||
const legacyEntries = previous.legacyBySkillRoot.get(
|
||||
entries[0].descriptor.legacySkillRoot
|
||||
) || [];
|
||||
|
||||
if (pathExists(flatSkillRoot) && !hasManagedFlatFile) {
|
||||
return {
|
||||
skippedOperations: entries.map(({ operation }) => operation),
|
||||
warnings: [createConflictWarning(
|
||||
entries[0].descriptor.skillName,
|
||||
flatSkillRoot,
|
||||
legacyEntries.length > 0
|
||||
)],
|
||||
retainedLegacyOperations: legacyEntries.map(({ operation }) => operation),
|
||||
};
|
||||
}
|
||||
|
||||
const conflicts = entries.filter(({ operation }) => (
|
||||
pathExists(operation.destinationPath)
|
||||
&& !previous.flatByDestination.has(comparablePath(operation.destinationPath))
|
||||
));
|
||||
return {
|
||||
skippedOperations: conflicts.map(({ operation }) => operation),
|
||||
warnings: conflicts.map(({ operation, descriptor }) => createFileConflictWarning(
|
||||
operation.destinationPath,
|
||||
previous.legacyBySource.has(descriptor.sourceKey)
|
||||
)),
|
||||
retainedLegacyOperations: conflicts
|
||||
.map(({ descriptor }) => previous.legacyBySource.get(descriptor.sourceKey))
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function classifySkillConflicts(currentGroups, previous) {
|
||||
const groupClassifications = [...currentGroups.entries()]
|
||||
.map(([flatSkillRoot, entries]) => classifySkillGroup(
|
||||
flatSkillRoot,
|
||||
entries,
|
||||
previous
|
||||
));
|
||||
const skippedOperations = groupClassifications
|
||||
.flatMap(classification => classification.skippedOperations);
|
||||
return {
|
||||
skippedOperations,
|
||||
skippedDestinations: new Set(
|
||||
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
||||
),
|
||||
warnings: groupClassifications.flatMap(classification => classification.warnings),
|
||||
retainedLegacyOperations: new Set([
|
||||
...collectRetainedLegacyOperations(currentGroups, previous),
|
||||
...groupClassifications.flatMap(
|
||||
classification => classification.retainedLegacyOperations
|
||||
),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMigrationStates(plan, previousState, previous, classification) {
|
||||
const { skippedDestinations, retainedLegacyOperations } = classification;
|
||||
const appliedOperations = plan.operations.filter(operation => (
|
||||
!skippedDestinations.has(comparablePath(operation.destinationPath))
|
||||
));
|
||||
const legacyOperations = [...previous.legacyBySource.values()];
|
||||
const legacyOperationsToRemove = legacyOperations.filter(operation => (
|
||||
!retainedLegacyOperations.has(operation)
|
||||
));
|
||||
const finalOperations = [
|
||||
...plan.statePreview.operations.filter(operation => (
|
||||
!skippedDestinations.has(comparablePath(operation.destinationPath))
|
||||
)),
|
||||
...retainedLegacyOperations,
|
||||
];
|
||||
const bridgeOperations = [
|
||||
...((previousState && previousState.operations) || []),
|
||||
...appliedOperations,
|
||||
];
|
||||
|
||||
return {
|
||||
appliedOperations,
|
||||
bridgeState: buildState(plan.statePreview, bridgeOperations),
|
||||
finalState: buildState(plan.statePreview, finalOperations),
|
||||
legacyOperationsToRemove,
|
||||
requiresBridgeState: appliedOperations.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareClaudeSkillMigration(plan) {
|
||||
const target = plan && plan.adapter && plan.adapter.target;
|
||||
if (!CLAUDE_TARGETS.has(target)) {
|
||||
return createDisabledMigration(plan);
|
||||
}
|
||||
|
||||
const previousState = pathExists(plan.installStatePath)
|
||||
? readInstallState(plan.installStatePath)
|
||||
: null;
|
||||
const currentGroups = groupCurrentSkillOperations(plan);
|
||||
const previous = classifyPreviousOperations(plan, previousState);
|
||||
const classification = classifySkillConflicts(currentGroups, previous);
|
||||
const states = buildMigrationStates(
|
||||
plan,
|
||||
previousState,
|
||||
previous,
|
||||
classification
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
appliedOperations: states.appliedOperations,
|
||||
skippedOperations: classification.skippedOperations,
|
||||
warnings: classification.warnings,
|
||||
bridgeState: states.bridgeState,
|
||||
finalState: states.finalState,
|
||||
legacyOperationsToRemove: states.legacyOperationsToRemove,
|
||||
requiresBridgeState: states.requiresBridgeState,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupEmptyLegacyParents(filePath, targetRoot) {
|
||||
const skillsRoot = path.join(targetRoot, 'skills');
|
||||
let currentPath = path.dirname(filePath);
|
||||
|
||||
while (!samePath(currentPath, skillsRoot)) {
|
||||
assertSafeSkillPath(currentPath, targetRoot, 'clean Claude skill migration');
|
||||
if (!pathExists(currentPath) || fs.readdirSync(currentPath).length > 0) {
|
||||
return;
|
||||
}
|
||||
fs.rmdirSync(currentPath);
|
||||
currentPath = path.dirname(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function removeLegacyClaudeSkillFiles(migration, targetRoot) {
|
||||
for (const operation of migration.legacyOperationsToRemove) {
|
||||
assertSafeSkillPath(
|
||||
operation.destinationPath,
|
||||
targetRoot,
|
||||
'migrate managed Claude skill'
|
||||
);
|
||||
fs.rmSync(operation.destinationPath, { force: true });
|
||||
cleanupEmptyLegacyParents(operation.destinationPath, targetRoot);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertSafeClaudeSkillOperation,
|
||||
prepareClaudeSkillMigration,
|
||||
removeLegacyClaudeSkillFiles,
|
||||
};
|
||||
@@ -22,7 +22,7 @@ function stripTrailingSlash(value) {
|
||||
// `fileMappings` is a list of { sourceRel, destRel } where both are paths
|
||||
// relative to the repo root and the install root respectively. The directory
|
||||
// map is derived by walking shared ancestors of each source/dest pair, which is
|
||||
// exact for prefix-insertion namespacing (e.g. `skills/x` -> `skills/ecc/x`):
|
||||
// exact for prefix-insertion namespacing (e.g. `rules/x` -> `rules/ecc/x`):
|
||||
// the path suffix below the inserted segment is preserved, so ancestor `k`
|
||||
// of the source maps to the dest with the matching number of trailing
|
||||
// segments removed.
|
||||
@@ -94,27 +94,16 @@ function resolveInstalledTarget(target, sourceDir, index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when the plan installs `sourceRel` at a different relative path than the
|
||||
// source (i.e. a namespace segment was injected, e.g. skills/x -> skills/ecc/x).
|
||||
// Callers use this to keep non-namespaced files on the byte-for-byte copy path.
|
||||
function isNamespacedSource(sourceRel, index) {
|
||||
const normalizedSource = toPosix(sourceRel);
|
||||
const installedSource = index && index.byFile.get(normalizedSource);
|
||||
return Boolean(installedSource) && installedSource !== normalizedSource;
|
||||
}
|
||||
|
||||
// Rewrite relative links in a single namespaced markdown file so they resolve
|
||||
// to the file's installed location. Returns the content unchanged when the
|
||||
// file itself was not namespaced or when no link needs adjustment. Pure: no IO.
|
||||
// Rewrite relative links in a markdown file so they resolve to installed target
|
||||
// locations. The source file may itself install at the same relative path; links
|
||||
// can still need changes when their targets move, such as rules -> rules/ecc.
|
||||
// Pure: no IO.
|
||||
function rewriteRelativeLinks(content, options) {
|
||||
const { sourceRel, index } = options || {};
|
||||
const normalizedSource = toPosix(sourceRel);
|
||||
const installedSource = index && index.byFile.get(normalizedSource);
|
||||
|
||||
// Only rewrite when the file's own install path gained/changed a namespace
|
||||
// segment. If it lands at the same relative path, every link recomputes to
|
||||
// itself, so there is nothing to do.
|
||||
if (!installedSource || installedSource === normalizedSource) {
|
||||
if (!installedSource) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -174,6 +163,5 @@ function rewriteRelativeLinks(content, options) {
|
||||
|
||||
module.exports = {
|
||||
buildInstallIndex,
|
||||
isNamespacedSource,
|
||||
rewriteRelativeLinks,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
'use strict';
|
||||
|
||||
const { TextDecoder } = require('util');
|
||||
|
||||
const MEMORY_SCHEMA_VERSION = 'ecc.memory.v1';
|
||||
const MEMORY_KINDS = Object.freeze([
|
||||
'context',
|
||||
'decision',
|
||||
'fact',
|
||||
'handoff',
|
||||
'lesson',
|
||||
'note',
|
||||
'preference',
|
||||
'runbook',
|
||||
]);
|
||||
const MEMORY_SCOPES = Object.freeze(['project', 'team', 'user']);
|
||||
const MEMORY_TRUST_STATES = Object.freeze(['unreviewed']);
|
||||
const MEMORY_STATUSES = Object.freeze(['active', 'rejected', 'superseded']);
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const MAX_DOCUMENT_BYTES = 128 * 1024;
|
||||
const MAX_TITLE_CHARS = 200;
|
||||
const MAX_TAGS = 32;
|
||||
const MAX_LINKS = 64;
|
||||
const MAX_TARGETS = 32;
|
||||
|
||||
const MEMORY_ID_PATTERN = /^mem_[a-z0-9][a-z0-9_-]{2,127}$/;
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
const FRONTMATTER_FIELDS = Object.freeze([
|
||||
['schema', 'schema'],
|
||||
['id', 'id'],
|
||||
['title', 'title'],
|
||||
['kind', 'kind'],
|
||||
['scope', 'scope'],
|
||||
['trust', 'trust'],
|
||||
['status', 'status'],
|
||||
['source_harness', 'sourceHarness'],
|
||||
['target_harnesses', 'targetHarnesses'],
|
||||
['tags', 'tags'],
|
||||
['links', 'links'],
|
||||
['created_at', 'createdAt'],
|
||||
['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 },
|
||||
{ label: 'Stripe key', pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/ },
|
||||
{ label: 'npm token', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/ },
|
||||
{ label: 'Hugging Face token', pattern: /\bhf_[A-Za-z0-9]{20,}\b/ },
|
||||
{ label: 'GitHub token', pattern: /\bgh[pors]_[A-Za-z0-9]{16,}\b/ },
|
||||
{ label: 'GitHub token', pattern: /\bgithub_pat_[A-Za-z0-9_]{16,}\b/ },
|
||||
{ label: 'Google API key', pattern: /\bAIza[A-Za-z0-9_-]{16,}\b/ },
|
||||
{ label: 'Slack token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
||||
{ label: 'AWS access key', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
|
||||
{ label: 'private key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
||||
]);
|
||||
|
||||
function hasUnsafeControlCharacters(value, allowBodyWhitespace = false) {
|
||||
return Array.from(value).some(character => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
const allowedWhitespace = allowBodyWhitespace
|
||||
&& (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d);
|
||||
const isControl = (codePoint <= 0x1f && !allowedWhitespace)
|
||||
|| (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
const isBidirectionalFormatting = (
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e)
|
||||
|| (codePoint >= 0x2066 && codePoint <= 0x2069)
|
||||
);
|
||||
return isControl || isBidirectionalFormatting;
|
||||
});
|
||||
}
|
||||
|
||||
function asNonEmptyString(value, label, maxChars = 10_000) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length > maxChars) {
|
||||
throw new Error(`${label} is too long (maximum ${maxChars} characters).`);
|
||||
}
|
||||
if (hasUnsafeControlCharacters(normalized)) {
|
||||
throw new Error(`${label} must not contain control or bidirectional formatting characters.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateEnum(value, allowed, label) {
|
||||
const normalized = asNonEmptyString(value, label, 64);
|
||||
if (!allowed.includes(normalized)) {
|
||||
throw new Error(`${label} must be one of: ${allowed.join(', ')}.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateSlug(value, label) {
|
||||
const normalized = asNonEmptyString(value, label, 64);
|
||||
if (!SLUG_PATTERN.test(normalized)) {
|
||||
throw new Error(`${label} must be a lowercase letters/numbers slug.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateMemoryId(value) {
|
||||
const normalized = asNonEmptyString(value, 'memory id', 132);
|
||||
if (!MEMORY_ID_PATTERN.test(normalized)) {
|
||||
throw new Error('memory id must match mem_<lowercase-id> and cannot contain a path.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function uniqueStrings(values, { label, limit, validator }) {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new Error(`${label} must be an array.`);
|
||||
}
|
||||
if (values.length > limit) {
|
||||
throw new Error(`${label} has too many values (maximum ${limit}).`);
|
||||
}
|
||||
return values.reduce((result, value) => {
|
||||
const normalized = validator(value);
|
||||
if (result.includes(normalized)) {
|
||||
throw new Error(`${label} must not contain duplicate values.`);
|
||||
}
|
||||
return [...result, normalized];
|
||||
}, []);
|
||||
}
|
||||
|
||||
function validateTimestamp(value, label) {
|
||||
const normalized = asNonEmptyString(value, label, 64);
|
||||
const parsed = new Date(normalized);
|
||||
if (
|
||||
!ISO_TIMESTAMP_PATTERN.test(normalized)
|
||||
|| Number.isNaN(parsed.getTime())
|
||||
|| parsed.toISOString() !== normalized
|
||||
) {
|
||||
throw new Error(`${label} must be an ISO-8601 timestamp.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeBody(value) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('memory body must be a string.');
|
||||
}
|
||||
if (hasUnsafeControlCharacters(value, true)) {
|
||||
throw new Error('memory body must not contain unsafe control or bidirectional formatting characters.');
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('memory body must contain non-whitespace context.');
|
||||
}
|
||||
if (Buffer.byteLength(normalized, 'utf8') > MAX_BODY_BYTES) {
|
||||
throw new Error(`memory body is too large (maximum ${MAX_BODY_BYTES} bytes).`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMemory(memory) {
|
||||
if (!memory || typeof memory !== 'object' || Array.isArray(memory)) {
|
||||
throw new Error('memory must be an object.');
|
||||
}
|
||||
|
||||
const targetHarnesses = uniqueStrings(memory.targetHarnesses, {
|
||||
label: 'target harnesses',
|
||||
limit: MAX_TARGETS,
|
||||
validator: value => validateSlug(value, 'target harness'),
|
||||
});
|
||||
if (targetHarnesses.length === 0) {
|
||||
throw new Error('target harnesses must contain at least one harness or "all".');
|
||||
}
|
||||
|
||||
if (memory.schema !== MEMORY_SCHEMA_VERSION) {
|
||||
throw new Error('Unsupported memory schema.');
|
||||
}
|
||||
|
||||
return {
|
||||
schema: memory.schema,
|
||||
id: validateMemoryId(memory.id),
|
||||
title: asNonEmptyString(memory.title, 'memory title', MAX_TITLE_CHARS),
|
||||
kind: validateEnum(memory.kind, MEMORY_KINDS, 'memory kind'),
|
||||
scope: validateEnum(memory.scope, MEMORY_SCOPES, 'memory scope'),
|
||||
trust: validateEnum(memory.trust, MEMORY_TRUST_STATES, 'memory trust'),
|
||||
status: validateEnum(memory.status, MEMORY_STATUSES, 'memory status'),
|
||||
sourceHarness: validateSlug(memory.sourceHarness, 'source harness'),
|
||||
targetHarnesses,
|
||||
tags: uniqueStrings(memory.tags, {
|
||||
label: 'tags',
|
||||
limit: MAX_TAGS,
|
||||
validator: value => validateSlug(value, 'tag'),
|
||||
}),
|
||||
links: uniqueStrings(memory.links, {
|
||||
label: 'links',
|
||||
limit: MAX_LINKS,
|
||||
validator: validateMemoryId,
|
||||
}),
|
||||
createdAt: validateTimestamp(memory.createdAt, 'created_at'),
|
||||
updatedAt: validateTimestamp(memory.updatedAt, 'updated_at'),
|
||||
body: normalizeBody(memory.body),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMemoryDocument(memory) {
|
||||
const normalized = normalizeMemory(memory);
|
||||
const metadata = FRONTMATTER_FIELDS.map(([serializedKey, objectKey]) => (
|
||||
`${serializedKey}: ${JSON.stringify(normalized[objectKey])}`
|
||||
)).join('\n');
|
||||
const body = normalized.body.length > 0 ? `\n\n${normalized.body}` : '';
|
||||
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) {
|
||||
throw new Error(`Invalid memory frontmatter line in ${sourcePath}.`);
|
||||
}
|
||||
const serializedKey = line.slice(0, separator).trim();
|
||||
const objectKey = FRONTMATTER_KEYS.get(serializedKey);
|
||||
if (!objectKey) {
|
||||
throw new Error(`Unknown memory frontmatter field in ${sourcePath}.`);
|
||||
}
|
||||
if (seen.has(objectKey)) {
|
||||
throw new Error(`Duplicate memory frontmatter field in ${sourcePath}.`);
|
||||
}
|
||||
const rawValue = line.slice(separator + 1).trim();
|
||||
try {
|
||||
return { objectKey, value: JSON.parse(rawValue) };
|
||||
} catch {
|
||||
throw new Error(`Memory frontmatter field in ${sourcePath} must use a JSON value.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMemoryDocument(source, sourcePath = '<memory>') {
|
||||
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 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 = remainder.slice(0, closingMarker.index);
|
||||
const parsed = frontmatterSource.split(/\r?\n/).reduce((state, line) => {
|
||||
const next = parseFrontmatterLine(line, sourcePath, state.seen);
|
||||
return {
|
||||
values: { ...state.values, [next.objectKey]: next.value },
|
||||
seen: new Set([...state.seen, next.objectKey]),
|
||||
};
|
||||
}, { values: {}, seen: new Set() });
|
||||
|
||||
const missing = FRONTMATTER_FIELDS
|
||||
.map(([, objectKey]) => objectKey)
|
||||
.filter(objectKey => !parsed.seen.has(objectKey));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Memory document ${sourcePath} is missing fields: ${missing.join(', ')}.`);
|
||||
}
|
||||
|
||||
const afterMarker = remainder.slice(closingMarker.index + closingMarker[0].length);
|
||||
const body = afterMarker.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||
return normalizeMemory({ ...parsed.values, body });
|
||||
}
|
||||
|
||||
function findPotentialSecrets(value) {
|
||||
const text = typeof value === 'string' ? value : '';
|
||||
return SECRET_PATTERNS
|
||||
.filter(item => item.pattern.test(text))
|
||||
.map(item => item.label)
|
||||
.filter((label, index, labels) => labels.indexOf(label) === index);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,778 @@
|
||||
'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) {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!opened.isFile()) {
|
||||
throw new Error(`${label} must be a regular, non-symlink file.`);
|
||||
}
|
||||
const after = fs.lstatSync(filePath);
|
||||
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 > 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);
|
||||
const after = fs.lstatSync(temporaryPath);
|
||||
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,
|
||||
saveMemory,
|
||||
scoreMemory,
|
||||
searchMemories,
|
||||
serializeMemoryDocument,
|
||||
tokenize,
|
||||
};
|
||||
Reference in New Issue
Block a user