Files
Regolith/skills-engine/customize.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

145 lines
4.4 KiB
TypeScript

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