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:
gavrielc
2026-02-19 01:55:00 +02:00
committed by GitHub
parent a689f8b3fa
commit 51788de3b9
83 changed files with 13159 additions and 626 deletions

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
areRangesCompatible,
mergeNpmDependencies,
mergeEnvAdditions,
mergeDockerComposeServices,
} from '../structured.js';
import { createTempDir, cleanup } from './test-helpers.js';
describe('structured', () => {
let tmpDir: string;
const originalCwd = process.cwd();
beforeEach(() => {
tmpDir = createTempDir();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(originalCwd);
cleanup(tmpDir);
});
describe('areRangesCompatible', () => {
it('identical versions are compatible', () => {
const result = areRangesCompatible('^1.0.0', '^1.0.0');
expect(result.compatible).toBe(true);
});
it('compatible ^ ranges resolve to higher', () => {
const result = areRangesCompatible('^1.0.0', '^1.1.0');
expect(result.compatible).toBe(true);
expect(result.resolved).toBe('^1.1.0');
});
it('incompatible major ^ ranges', () => {
const result = areRangesCompatible('^1.0.0', '^2.0.0');
expect(result.compatible).toBe(false);
});
it('compatible ~ ranges', () => {
const result = areRangesCompatible('~1.0.0', '~1.0.3');
expect(result.compatible).toBe(true);
expect(result.resolved).toBe('~1.0.3');
});
it('mismatched prefixes are incompatible', () => {
const result = areRangesCompatible('^1.0.0', '~1.0.0');
expect(result.compatible).toBe(false);
});
it('handles double-digit version parts numerically', () => {
// ^1.9.0 vs ^1.10.0 — 10 > 9 numerically, but "9" > "10" as strings
const result = areRangesCompatible('^1.9.0', '^1.10.0');
expect(result.compatible).toBe(true);
expect(result.resolved).toBe('^1.10.0');
});
it('handles double-digit patch versions', () => {
const result = areRangesCompatible('~1.0.9', '~1.0.10');
expect(result.compatible).toBe(true);
expect(result.resolved).toBe('~1.0.10');
});
});
describe('mergeNpmDependencies', () => {
it('adds new dependencies', () => {
const pkgPath = path.join(tmpDir, 'package.json');
fs.writeFileSync(pkgPath, JSON.stringify({
name: 'test',
dependencies: { existing: '^1.0.0' },
}, null, 2));
mergeNpmDependencies(pkgPath, { newdep: '^2.0.0' });
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
expect(pkg.dependencies.newdep).toBe('^2.0.0');
expect(pkg.dependencies.existing).toBe('^1.0.0');
});
it('resolves compatible ^ ranges', () => {
const pkgPath = path.join(tmpDir, 'package.json');
fs.writeFileSync(pkgPath, JSON.stringify({
name: 'test',
dependencies: { dep: '^1.0.0' },
}, null, 2));
mergeNpmDependencies(pkgPath, { dep: '^1.1.0' });
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
expect(pkg.dependencies.dep).toBe('^1.1.0');
});
it('sorts devDependencies after merge', () => {
const pkgPath = path.join(tmpDir, 'package.json');
fs.writeFileSync(pkgPath, JSON.stringify({
name: 'test',
dependencies: {},
devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' },
}, null, 2));
mergeNpmDependencies(pkgPath, { middle: '^1.0.0' });
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const devKeys = Object.keys(pkg.devDependencies);
expect(devKeys).toEqual(['acorn', 'zlib']);
});
it('throws on incompatible major versions', () => {
const pkgPath = path.join(tmpDir, 'package.json');
fs.writeFileSync(pkgPath, JSON.stringify({
name: 'test',
dependencies: { dep: '^1.0.0' },
}, null, 2));
expect(() => mergeNpmDependencies(pkgPath, { dep: '^2.0.0' })).toThrow();
});
});
describe('mergeEnvAdditions', () => {
it('adds new variables', () => {
const envPath = path.join(tmpDir, '.env.example');
fs.writeFileSync(envPath, 'EXISTING_VAR=value\n');
mergeEnvAdditions(envPath, ['NEW_VAR']);
const content = fs.readFileSync(envPath, 'utf-8');
expect(content).toContain('NEW_VAR=');
expect(content).toContain('EXISTING_VAR=value');
});
it('skips existing variables', () => {
const envPath = path.join(tmpDir, '.env.example');
fs.writeFileSync(envPath, 'MY_VAR=original\n');
mergeEnvAdditions(envPath, ['MY_VAR']);
const content = fs.readFileSync(envPath, 'utf-8');
// Should not add duplicate - only 1 occurrence of MY_VAR=
const matches = content.match(/MY_VAR=/g);
expect(matches).toHaveLength(1);
});
it('recognizes lowercase and mixed-case env vars as existing', () => {
const envPath = path.join(tmpDir, '.env.example');
fs.writeFileSync(envPath, 'my_lower_var=value\nMixed_Case=abc\n');
mergeEnvAdditions(envPath, ['my_lower_var', 'Mixed_Case']);
const content = fs.readFileSync(envPath, 'utf-8');
// Should not add duplicates
const lowerMatches = content.match(/my_lower_var=/g);
expect(lowerMatches).toHaveLength(1);
const mixedMatches = content.match(/Mixed_Case=/g);
expect(mixedMatches).toHaveLength(1);
});
it('creates file if it does not exist', () => {
const envPath = path.join(tmpDir, '.env.example');
mergeEnvAdditions(envPath, ['NEW_VAR']);
expect(fs.existsSync(envPath)).toBe(true);
const content = fs.readFileSync(envPath, 'utf-8');
expect(content).toContain('NEW_VAR=');
});
});
describe('mergeDockerComposeServices', () => {
it('adds new services', () => {
const composePath = path.join(tmpDir, 'docker-compose.yaml');
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n');
mergeDockerComposeServices(composePath, {
redis: { image: 'redis:7' },
});
const content = fs.readFileSync(composePath, 'utf-8');
expect(content).toContain('redis');
});
it('skips existing services', () => {
const composePath = path.join(tmpDir, 'docker-compose.yaml');
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n');
mergeDockerComposeServices(composePath, {
web: { image: 'apache' },
});
const content = fs.readFileSync(composePath, 'utf-8');
expect(content).toContain('nginx');
});
it('throws on port collision', () => {
const composePath = path.join(tmpDir, 'docker-compose.yaml');
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n ports:\n - "8080:80"\n');
expect(() => mergeDockerComposeServices(composePath, {
api: { image: 'node', ports: ['8080:3000'] },
})).toThrow();
});
});
});