Files
Regolith/skills-engine/resolution-cache.ts
gavrielc 51788de3b9 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>
2026-02-19 01:55:00 +02:00

270 lines
8.7 KiB
TypeScript

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;
}