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:
144
skills-engine/customize.ts
Normal file
144
skills-engine/customize.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { BASE_DIR, CUSTOM_DIR } from './constants.js';
|
||||
import { computeFileHash, readState, recordCustomModification } from './state.js';
|
||||
|
||||
interface PendingCustomize {
|
||||
description: string;
|
||||
started_at: string;
|
||||
file_hashes: Record<string, string>;
|
||||
}
|
||||
|
||||
function getPendingPath(): string {
|
||||
return path.join(process.cwd(), CUSTOM_DIR, 'pending.yaml');
|
||||
}
|
||||
|
||||
export function isCustomizeActive(): boolean {
|
||||
return fs.existsSync(getPendingPath());
|
||||
}
|
||||
|
||||
export function startCustomize(description: string): void {
|
||||
if (isCustomizeActive()) {
|
||||
throw new Error(
|
||||
'A customize session is already active. Commit or abort it first.',
|
||||
);
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
|
||||
// Collect all file hashes from applied skills
|
||||
const fileHashes: Record<string, string> = {};
|
||||
for (const skill of state.applied_skills) {
|
||||
for (const [relativePath, hash] of Object.entries(skill.file_hashes)) {
|
||||
fileHashes[relativePath] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
const pending: PendingCustomize = {
|
||||
description,
|
||||
started_at: new Date().toISOString(),
|
||||
file_hashes: fileHashes,
|
||||
};
|
||||
|
||||
const customDir = path.join(process.cwd(), CUSTOM_DIR);
|
||||
fs.mkdirSync(customDir, { recursive: true });
|
||||
fs.writeFileSync(getPendingPath(), stringify(pending), 'utf-8');
|
||||
}
|
||||
|
||||
export function commitCustomize(): void {
|
||||
const pendingPath = getPendingPath();
|
||||
if (!fs.existsSync(pendingPath)) {
|
||||
throw new Error('No active customize session. Run startCustomize() first.');
|
||||
}
|
||||
|
||||
const pending = parse(
|
||||
fs.readFileSync(pendingPath, 'utf-8'),
|
||||
) as PendingCustomize;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Find files that changed
|
||||
const changedFiles: string[] = [];
|
||||
for (const relativePath of Object.keys(pending.file_hashes)) {
|
||||
const fullPath = path.join(cwd, relativePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
// File was deleted — counts as changed
|
||||
changedFiles.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
const currentHash = computeFileHash(fullPath);
|
||||
if (currentHash !== pending.file_hashes[relativePath]) {
|
||||
changedFiles.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No files changed during customize session. Nothing to commit.');
|
||||
fs.unlinkSync(pendingPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unified diff for each changed file
|
||||
const baseDir = path.join(cwd, BASE_DIR);
|
||||
let combinedPatch = '';
|
||||
|
||||
for (const relativePath of changedFiles) {
|
||||
const basePath = path.join(baseDir, relativePath);
|
||||
const currentPath = path.join(cwd, relativePath);
|
||||
|
||||
// Use /dev/null if either side doesn't exist
|
||||
const oldPath = fs.existsSync(basePath) ? basePath : '/dev/null';
|
||||
const newPath = fs.existsSync(currentPath) ? currentPath : '/dev/null';
|
||||
|
||||
try {
|
||||
const diff = execFileSync('diff', ['-ruN', oldPath, newPath], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
combinedPatch += diff;
|
||||
} catch (err: unknown) {
|
||||
const execErr = err as { status?: number; stdout?: string };
|
||||
if (execErr.status === 1 && execErr.stdout) {
|
||||
// diff exits 1 when files differ — that's expected
|
||||
combinedPatch += execErr.stdout;
|
||||
} else if (execErr.status === 2) {
|
||||
throw new Error(`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!combinedPatch.trim()) {
|
||||
console.log('Diff was empty despite hash changes. Nothing to commit.');
|
||||
fs.unlinkSync(pendingPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine sequence number
|
||||
const state = readState();
|
||||
const existingCount = state.custom_modifications?.length ?? 0;
|
||||
const seqNum = String(existingCount + 1).padStart(3, '0');
|
||||
|
||||
// Sanitize description for filename
|
||||
const sanitized = pending.description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const patchFilename = `${seqNum}-${sanitized}.patch`;
|
||||
const patchRelPath = path.join(CUSTOM_DIR, patchFilename);
|
||||
const patchFullPath = path.join(cwd, patchRelPath);
|
||||
|
||||
fs.writeFileSync(patchFullPath, combinedPatch, 'utf-8');
|
||||
recordCustomModification(pending.description, changedFiles, patchRelPath);
|
||||
fs.unlinkSync(pendingPath);
|
||||
}
|
||||
|
||||
export function abortCustomize(): void {
|
||||
const pendingPath = getPendingPath();
|
||||
if (fs.existsSync(pendingPath)) {
|
||||
fs.unlinkSync(pendingPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user