Skills engine v0.1 + multi-channel infrastructure (#307)
* refactor: multi-channel infrastructure with explicit channel/is_group tracking - Add channels[] array and findChannel() routing in index.ts, replacing hardcoded whatsapp.* calls with channel-agnostic callbacks - Add channel TEXT and is_group INTEGER columns to chats table with COALESCE upsert to protect existing values from null overwrites - is_group defaults to 0 (safe: unknown chats excluded from groups) - WhatsApp passes explicit channel='whatsapp' and isGroup to onChatMetadata - getAvailableGroups filters on is_group instead of JID pattern matching - findChannel logs warnings instead of silently dropping unroutable JIDs - Migration backfills channel/is_group from JID patterns for existing DBs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: skills engine v0.1 — deterministic skill packages with rerere resolution Three-way merge engine for applying skill packages on top of a core codebase. Skills declare which files they add/modify, and the engine uses git merge-file for conflict detection with git rerere for automatic resolution of previously-seen conflicts. Key components: - apply: three-way merge with backup/rollback safety net - replay: clean-slate replay for uninstall and rebase - update: core version updates with deletion detection - rebase: bake applied skills into base (one-way) - manifest: validation with path traversal protection - resolution-cache: pre-computed rerere resolutions - structured: npm deps, env vars, docker-compose merging - CI: per-skill test matrix with conflict detection 151 unit tests covering merge, rerere, backup, replay, uninstall, update, rebase, structured ops, and edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Discord and Telegram skill packages Skill packages for adding Discord and Telegram channels to NanoClaw. Each package includes: - Channel implementation (add/src/channels/) - Three-way merge targets for index.ts, config.ts, routing.test.ts - Intent docs explaining merge invariants - Standalone integration tests - manifest.yaml with dependency/conflict declarations Applied via: npx tsx scripts/apply-skill.ts .claude/skills/add-discord These are inert until applied — no runtime impact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * remove unused docs (skills-system-status, implementation-guide) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
269
skills-engine/resolution-cache.ts
Normal file
269
skills-engine/resolution-cache.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { NANOCLAW_DIR, RESOLUTIONS_DIR, SHIPPED_RESOLUTIONS_DIR } from './constants.js';
|
||||
import { computeFileHash } from './state.js';
|
||||
import { FileInputHashes, ResolutionMeta } from './types.js';
|
||||
|
||||
/**
|
||||
* Build the resolution directory key from a set of skill identifiers.
|
||||
* Skills are sorted alphabetically and joined with "+".
|
||||
*/
|
||||
function resolutionKey(skills: string[]): string {
|
||||
return [...skills].sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the resolution directory for a given skill combination.
|
||||
* Returns absolute path if it exists, null otherwise.
|
||||
*/
|
||||
export function findResolutionDir(
|
||||
skills: string[],
|
||||
projectRoot: string,
|
||||
): string | null {
|
||||
const key = resolutionKey(skills);
|
||||
|
||||
// Check shipped resolutions (.claude/resolutions/) first, then project-level
|
||||
for (const baseDir of [SHIPPED_RESOLUTIONS_DIR, RESOLUTIONS_DIR]) {
|
||||
const dir = path.join(projectRoot, baseDir, key);
|
||||
if (fs.existsSync(dir)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached resolutions into the local git rerere cache.
|
||||
* Verifies file_hashes from meta.yaml match before loading each pair.
|
||||
* Returns true if loaded successfully, false if not found or no pairs loaded.
|
||||
*/
|
||||
export function loadResolutions(
|
||||
skills: string[],
|
||||
projectRoot: string,
|
||||
skillDir: string,
|
||||
): boolean {
|
||||
const resDir = findResolutionDir(skills, projectRoot);
|
||||
if (!resDir) return false;
|
||||
|
||||
const metaPath = path.join(resDir, 'meta.yaml');
|
||||
if (!fs.existsSync(metaPath)) return false;
|
||||
|
||||
let meta: ResolutionMeta;
|
||||
try {
|
||||
meta = parse(fs.readFileSync(metaPath, 'utf-8')) as ResolutionMeta;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!meta.input_hashes) return false;
|
||||
|
||||
// Find all preimage/resolution pairs
|
||||
const pairs = findPreimagePairs(resDir, resDir);
|
||||
if (pairs.length === 0) return false;
|
||||
|
||||
// Get the git directory
|
||||
let gitDir: string;
|
||||
try {
|
||||
gitDir = execSync('git rev-parse --git-dir', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectRoot,
|
||||
}).trim();
|
||||
if (!path.isAbsolute(gitDir)) {
|
||||
gitDir = path.join(projectRoot, gitDir);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rrCacheDir = path.join(gitDir, 'rr-cache');
|
||||
let loadedAny = false;
|
||||
|
||||
for (const { relPath, preimage, resolution } of pairs) {
|
||||
// Verify file_hashes — skip pair if hashes don't match
|
||||
const expected = meta.file_hashes?.[relPath];
|
||||
if (!expected) {
|
||||
console.log(`resolution-cache: skipping ${relPath} — no file_hashes in meta`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const basePath = path.join(projectRoot, NANOCLAW_DIR, 'base', relPath);
|
||||
const currentPath = path.join(projectRoot, relPath);
|
||||
const skillModifyPath = path.join(skillDir, 'modify', relPath);
|
||||
|
||||
if (!fs.existsSync(basePath) || !fs.existsSync(currentPath) || !fs.existsSync(skillModifyPath)) {
|
||||
console.log(`resolution-cache: skipping ${relPath} — input files not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseHash = computeFileHash(basePath);
|
||||
if (baseHash !== expected.base) {
|
||||
console.log(`resolution-cache: skipping ${relPath} — base hash mismatch`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentHash = computeFileHash(currentPath);
|
||||
if (currentHash !== expected.current) {
|
||||
console.log(`resolution-cache: skipping ${relPath} — current hash mismatch`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillHash = computeFileHash(skillModifyPath);
|
||||
if (skillHash !== expected.skill) {
|
||||
console.log(`resolution-cache: skipping ${relPath} — skill hash mismatch`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const preimageContent = fs.readFileSync(preimage, 'utf-8');
|
||||
const resolutionContent = fs.readFileSync(resolution, 'utf-8');
|
||||
|
||||
// Git rerere uses its own internal hash format (not git hash-object).
|
||||
// We store the rerere hash in the preimage filename as a .hash sidecar,
|
||||
// captured when saveResolution() reads the actual rr-cache after rerere records it.
|
||||
const hashSidecar = preimage + '.hash';
|
||||
if (!fs.existsSync(hashSidecar)) {
|
||||
// No hash recorded — skip this pair (legacy format)
|
||||
continue;
|
||||
}
|
||||
const hash = fs.readFileSync(hashSidecar, 'utf-8').trim();
|
||||
if (!hash) continue;
|
||||
|
||||
// Create rr-cache entry
|
||||
const cacheDir = path.join(rrCacheDir, hash);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheDir, 'preimage'), preimageContent);
|
||||
fs.writeFileSync(path.join(cacheDir, 'postimage'), resolutionContent);
|
||||
loadedAny = true;
|
||||
}
|
||||
|
||||
return loadedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conflict resolutions to the resolution cache.
|
||||
*/
|
||||
export function saveResolution(
|
||||
skills: string[],
|
||||
files: { relPath: string; preimage: string; resolution: string; inputHashes: FileInputHashes }[],
|
||||
meta: Partial<ResolutionMeta>,
|
||||
projectRoot: string,
|
||||
): void {
|
||||
const key = resolutionKey(skills);
|
||||
const resDir = path.join(projectRoot, RESOLUTIONS_DIR, key);
|
||||
|
||||
// Get the git rr-cache directory to find actual rerere hashes
|
||||
let rrCacheDir: string | null = null;
|
||||
try {
|
||||
let gitDir = execSync('git rev-parse --git-dir', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectRoot,
|
||||
}).trim();
|
||||
if (!path.isAbsolute(gitDir)) {
|
||||
gitDir = path.join(projectRoot, gitDir);
|
||||
}
|
||||
rrCacheDir = path.join(gitDir, 'rr-cache');
|
||||
} catch {
|
||||
// Not a git repo — skip hash capture
|
||||
}
|
||||
|
||||
// Write preimage/resolution pairs
|
||||
for (const file of files) {
|
||||
const preimagePath = path.join(resDir, file.relPath + '.preimage');
|
||||
const resolutionPath = path.join(resDir, file.relPath + '.resolution');
|
||||
|
||||
fs.mkdirSync(path.dirname(preimagePath), { recursive: true });
|
||||
fs.writeFileSync(preimagePath, file.preimage);
|
||||
fs.writeFileSync(resolutionPath, file.resolution);
|
||||
|
||||
// Capture the actual rerere hash by finding the rr-cache entry
|
||||
// whose preimage matches ours
|
||||
if (rrCacheDir && fs.existsSync(rrCacheDir)) {
|
||||
const rerereHash = findRerereHash(rrCacheDir, file.preimage);
|
||||
if (rerereHash) {
|
||||
fs.writeFileSync(preimagePath + '.hash', rerereHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect file_hashes from individual files
|
||||
const fileHashes: Record<string, FileInputHashes> = {};
|
||||
for (const file of files) {
|
||||
fileHashes[file.relPath] = file.inputHashes;
|
||||
}
|
||||
|
||||
// Build full meta with defaults
|
||||
const fullMeta: ResolutionMeta = {
|
||||
skills: [...skills].sort(),
|
||||
apply_order: meta.apply_order ?? skills,
|
||||
core_version: meta.core_version ?? '',
|
||||
resolved_at: meta.resolved_at ?? new Date().toISOString(),
|
||||
tested: meta.tested ?? false,
|
||||
test_passed: meta.test_passed ?? false,
|
||||
resolution_source: meta.resolution_source ?? 'user',
|
||||
input_hashes: meta.input_hashes ?? {},
|
||||
output_hash: meta.output_hash ?? '',
|
||||
file_hashes: { ...fileHashes, ...meta.file_hashes },
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(resDir, 'meta.yaml'), stringify(fullMeta));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all resolution cache entries.
|
||||
* Called after rebase since the base has changed and old resolutions are invalid.
|
||||
*/
|
||||
export function clearAllResolutions(projectRoot: string): void {
|
||||
const resDir = path.join(projectRoot, RESOLUTIONS_DIR);
|
||||
if (fs.existsSync(resDir)) {
|
||||
fs.rmSync(resDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(resDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find preimage/resolution pairs in a directory.
|
||||
*/
|
||||
function findPreimagePairs(
|
||||
dir: string,
|
||||
baseDir: string,
|
||||
): { relPath: string; preimage: string; resolution: string }[] {
|
||||
const pairs: { relPath: string; preimage: string; resolution: string }[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
pairs.push(...findPreimagePairs(fullPath, baseDir));
|
||||
} else if (entry.name.endsWith('.preimage') && !entry.name.endsWith('.preimage.hash')) {
|
||||
const resolutionPath = fullPath.replace(/\.preimage$/, '.resolution');
|
||||
if (fs.existsSync(resolutionPath)) {
|
||||
const relPath = path.relative(baseDir, fullPath).replace(/\.preimage$/, '');
|
||||
pairs.push({ relPath, preimage: fullPath, resolution: resolutionPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the rerere hash for a given preimage by scanning rr-cache entries.
|
||||
* Returns the directory name (hash) whose preimage matches the given content.
|
||||
*/
|
||||
function findRerereHash(rrCacheDir: string, preimageContent: string): string | null {
|
||||
if (!fs.existsSync(rrCacheDir)) return null;
|
||||
|
||||
for (const entry of fs.readdirSync(rrCacheDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const preimagePath = path.join(rrCacheDir, entry.name, 'preimage');
|
||||
if (fs.existsSync(preimagePath)) {
|
||||
const content = fs.readFileSync(preimagePath, 'utf-8');
|
||||
if (content === preimageContent) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user