diff --git a/agents/haonan/sessions/sessions.json b/agents/haonan/sessions/sessions.json index 82c2169..c1b63bc 100644 --- a/agents/haonan/sessions/sessions.json +++ b/agents/haonan/sessions/sessions.json @@ -1,13 +1,13 @@ { "agent:haonan:main": { - "sessionId": "dbaa7e83-9ffb-4289-8d34-3274f6b6c6b2", - "updatedAt": 1772478031056, + "sessionId": "424f34e7-c7ca-4812-8b7c-0430fa82f0a1", + "updatedAt": 1772550677322, "systemSent": true, "skillsSnapshot": { "prompt": "", "skills": [], "resolvedSkills": [], - "version": 0 + "version": 1772550218581 }, "deliveryContext": { "channel": "feishu", @@ -17,26 +17,24 @@ "lastChannel": "feishu", "lastTo": "user:ou_be83febac9ee2afaa343c642bc697c48", "lastAccountId": "feishu-pm", - "channel": "feishu", "modelProvider": "mm", "model": "MiniMax-M2.5", - "contextTokens": 200000, "abortedLastRun": false, - "inputTokens": 32, - "outputTokens": 69, - "totalTokens": 92944, "chatType": "direct", "origin": { - "label": "user:ou_be83febac9ee2afaa343c642bc697c48", - "provider": "cron-event", - "from": "user:ou_be83febac9ee2afaa343c642bc697c48", - "to": "user:ou_be83febac9ee2afaa343c642bc697c48" + "label": "张青", + "provider": "feishu", + "surface": "feishu", + "chatType": "direct", + "from": "feishu:ou_be83febac9ee2afaa343c642bc697c48", + "to": "user:ou_be83febac9ee2afaa343c642bc697c48", + "accountId": "feishu-pm" }, - "sessionFile": "/home/starmen1265/.openclaw/agents/haonan/sessions/dbaa7e83-9ffb-4289-8d34-3274f6b6c6b2.jsonl", + "sessionFile": "/home/starmen1265/.openclaw/agents/haonan/sessions/424f34e7-c7ca-4812-8b7c-0430fa82f0a1.jsonl", "systemPromptReport": { "source": "run", - "generatedAt": 1772478022849, - "sessionId": "dbaa7e83-9ffb-4289-8d34-3274f6b6c6b2", + "generatedAt": 1772550534184, + "sessionId": "424f34e7-c7ca-4812-8b7c-0430fa82f0a1", "sessionKey": "agent:haonan:main", "provider": "mm", "model": "MiniMax-M2.5", @@ -47,9 +45,9 @@ "sandboxed": false }, "systemPrompt": { - "chars": 25697, + "chars": 25880, "projectContextChars": 16878, - "nonProjectContextChars": 8819 + "nonProjectContextChars": 9002 }, "injectedWorkspaceFiles": [ { @@ -285,7 +283,14 @@ } }, "lastHeartbeatText": "蒋先生,OpenClaw 配置的定时备份已成功完成 ✅\n\n备份文件:`openclaw.json.2026-03-03_03-00-11`", - "lastHeartbeatSentAt": 1772478022807 + "lastHeartbeatSentAt": 1772478022807, + "compactionCount": 0, + "inputTokens": 32, + "outputTokens": 279, + "totalTokens": 42792, + "contextTokens": 200000, + "memoryFlushAt": 1772550355815, + "memoryFlushCompactionCount": 0 }, "agent:haonan:wecom:dm:zhangqing": { "origin": { diff --git a/cron/jobs.json b/cron/jobs.json index ef6ea4b..0ecac2c 100644 --- a/cron/jobs.json +++ b/cron/jobs.json @@ -29,6 +29,36 @@ "lastDurationMs": 22529, "consecutiveErrors": 0 } + }, + { + "id": "f03b184f-5f1d-4093-ae1b-fc9fe0b59eef", + "agentId": "haonan", + "name": "capability-evolver-daily", + "enabled": true, + "createdAtMs": 1772550743528, + "updatedAtMs": 1772550770239, + "schedule": { + "kind": "cron", + "expr": "0 12 * * *", + "tz": "Asia/Shanghai" + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "执行每日 capability-evolver 任务:\n\n1. 执行脚本:bash ~/.openclaw/scripts/evolver-run.sh\n2. 读取输出:cat ~/.openclaw/scripts/evolver-output.txt\n3. 将完整的修改内容(包含 Git 备份信息和本次变更)通过 feishu 发送给蒋先生\n\n如果本次无任何变更,回复 '今日无变更' 即可。", + "model": "g47f", + "thinking": "off", + "timeoutSeconds": 300 + }, + "delivery": { + "mode": "announce", + "channel": "feishu", + "to": "feishu-pm" + }, + "state": { + "nextRunAtMs": 1772596800000 + } } ] } \ No newline at end of file diff --git a/cron/jobs.json.bak b/cron/jobs.json.bak index ef6ea4b..0ecac2c 100644 --- a/cron/jobs.json.bak +++ b/cron/jobs.json.bak @@ -29,6 +29,36 @@ "lastDurationMs": 22529, "consecutiveErrors": 0 } + }, + { + "id": "f03b184f-5f1d-4093-ae1b-fc9fe0b59eef", + "agentId": "haonan", + "name": "capability-evolver-daily", + "enabled": true, + "createdAtMs": 1772550743528, + "updatedAtMs": 1772550770239, + "schedule": { + "kind": "cron", + "expr": "0 12 * * *", + "tz": "Asia/Shanghai" + }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "执行每日 capability-evolver 任务:\n\n1. 执行脚本:bash ~/.openclaw/scripts/evolver-run.sh\n2. 读取输出:cat ~/.openclaw/scripts/evolver-output.txt\n3. 将完整的修改内容(包含 Git 备份信息和本次变更)通过 feishu 发送给蒋先生\n\n如果本次无任何变更,回复 '今日无变更' 即可。", + "model": "g47f", + "thinking": "off", + "timeoutSeconds": 300 + }, + "delivery": { + "mode": "announce", + "channel": "feishu", + "to": "feishu-pm" + }, + "state": { + "nextRunAtMs": 1772596800000 + } } ] } \ No newline at end of file diff --git a/devices/paired.json b/devices/paired.json index d87a584..5f6401a 100644 --- a/devices/paired.json +++ b/devices/paired.json @@ -3,8 +3,8 @@ "deviceId": "a12f851ec2a179ca97d45b204bc3788476f062520fa77c5edef5cd054a176b1e", "publicKey": "Ka6zP7u6MzDaQBqOpXHYUBC5MYLlVGGZM8vIi11WnTw", "platform": "linux", - "clientId": "cli", - "clientMode": "cli", + "clientId": "gateway-client", + "clientMode": "backend", "role": "operator", "roles": [ "operator" @@ -24,11 +24,12 @@ "operator.pairing" ], "createdAtMs": 1770807648254, - "lastUsedAtMs": 1772478015399 + "lastUsedAtMs": 1772550770205 } }, "createdAtMs": 1770807648254, "approvedAtMs": 1770807648254, - "remoteIp": "192.168.1.36" + "remoteIp": "192.168.1.36", + "displayName": "agent" } } \ No newline at end of file diff --git a/identity/device-auth.json b/identity/device-auth.json index 545bb71..ca0ea23 100644 --- a/identity/device-auth.json +++ b/identity/device-auth.json @@ -10,7 +10,7 @@ "operator.approvals", "operator.pairing" ], - "updatedAtMs": 1772478015413 + "updatedAtMs": 1772550770236 } } } diff --git a/openclaw.json b/openclaw.json index 05460ad..4026828 100644 --- a/openclaw.json +++ b/openclaw.json @@ -1,7 +1,7 @@ { "meta": { "lastTouchedVersion": "2026.2.9", - "lastTouchedAt": "2026-02-15T15:57:58.358Z" + "lastTouchedAt": "2026-03-03T15:06:05.649Z" }, "wizard": { "lastRunAt": "2026-02-13T15:47:05.928Z", @@ -147,70 +147,45 @@ "list": [ { "id": "haonan", - "name": "\u9648\u6d69\u5357", - "workspace": "/home/starmen1265/.openclaw/agents/haonan/workspace", - "model": "m25", "skills": [ - "common-require" - ], - "identity": { - "name": "\u9648\u6d69\u5357", - "emoji": "\ud83d\udcda" - } + "common-require", + "capability-evolver", + "self-improving-agent" + ] }, { "id": "knowledge-base", - "name": "\u77e5\u8bc6\u5e93\u52a9\u624b", - "workspace": "/home/starmen1265/.openclaw/agents/knowledge-base/workspace", - "model": "g5", "skills": [ "common-require", - "kb-workflow" - ], - "identity": { - "name": "\u77e5\u8bc6\u5e93\u52a9\u624b", - "emoji": "\ud83d\udcda" - } + "kb-workflow", + "capability-evolver", + "self-improving-agent" + ] }, { "id": "dev-assist", - "name": "\u7a0b\u5e8f\u5f00\u53d1\u52a9\u624b", - "workspace": "/home/starmen1265/.openclaw/agents/dev-assist/workspace", - "model": "g5", "skills": [ "common-require", - "clawlist" - ], - "identity": { - "name": "\u7a0b\u5e8f\u5f00\u53d1\u52a9\u624b", - "emoji": "\ud83d\udcda" - } + "clawlist", + "capability-evolver", + "self-improving-agent" + ] }, { "id": "ui-designer", - "name": "UI/UX\u8bbe\u8ba1\u5e08", - "workspace": "/home/starmen1265/.openclaw/agents/ui-designer/workspace", - "model": "g5", "skills": [ - "common-require" - ], - "identity": { - "name": "\u8bbe\u8ba1\u5e08", - "emoji": "\ud83c\udfa8" - } + "common-require", + "capability-evolver", + "self-improving-agent" + ] }, { "id": "thinker", - "name": "\u5ba1\u67e5\u5458", - "workspace": "/home/starmen1265/.openclaw/agents/thinker/workspace", - "model": "g5", "skills": [ - "common-require" - ], - "identity": { - "name": "\u5ba1\u67e5\u5458", - "emoji": "\ud83d\udd0d" - } + "common-require", + "capability-evolver", + "self-improving-agent" + ] } ] }, diff --git a/openclaw.json.bak b/openclaw.json.bak index 35ed342..05460ad 100644 --- a/openclaw.json.bak +++ b/openclaw.json.bak @@ -147,20 +147,20 @@ "list": [ { "id": "haonan", - "name": "陈浩南", + "name": "\u9648\u6d69\u5357", "workspace": "/home/starmen1265/.openclaw/agents/haonan/workspace", "model": "m25", "skills": [ "common-require" ], "identity": { - "name": "陈浩南", - "emoji": "📚" + "name": "\u9648\u6d69\u5357", + "emoji": "\ud83d\udcda" } }, { "id": "knowledge-base", - "name": "知识库助手", + "name": "\u77e5\u8bc6\u5e93\u52a9\u624b", "workspace": "/home/starmen1265/.openclaw/agents/knowledge-base/workspace", "model": "g5", "skills": [ @@ -168,13 +168,13 @@ "kb-workflow" ], "identity": { - "name": "知识库助手", - "emoji": "📚" + "name": "\u77e5\u8bc6\u5e93\u52a9\u624b", + "emoji": "\ud83d\udcda" } }, { "id": "dev-assist", - "name": "程序开发助手", + "name": "\u7a0b\u5e8f\u5f00\u53d1\u52a9\u624b", "workspace": "/home/starmen1265/.openclaw/agents/dev-assist/workspace", "model": "g5", "skills": [ @@ -182,21 +182,34 @@ "clawlist" ], "identity": { - "name": "程序开发助手", - "emoji": "📚" + "name": "\u7a0b\u5e8f\u5f00\u53d1\u52a9\u624b", + "emoji": "\ud83d\udcda" } }, { "id": "ui-designer", - "name": "UI/UX设计师", + "name": "UI/UX\u8bbe\u8ba1\u5e08", "workspace": "/home/starmen1265/.openclaw/agents/ui-designer/workspace", "model": "g5", "skills": [ "common-require" ], "identity": { - "name": "设计师", - "emoji": "🎨" + "name": "\u8bbe\u8ba1\u5e08", + "emoji": "\ud83c\udfa8" + } + }, + { + "id": "thinker", + "name": "\u5ba1\u67e5\u5458", + "workspace": "/home/starmen1265/.openclaw/agents/thinker/workspace", + "model": "g5", + "skills": [ + "common-require" + ], + "identity": { + "name": "\u5ba1\u67e5\u5458", + "emoji": "\ud83d\udd0d" } } ] @@ -229,71 +242,38 @@ }, "bindings": [ { - "agentId": "knowledge-base", + "agentId": "haonan", "match": { "channel": "feishu", - "peer": { - "kind": "group", - "id": "oc_79bcbd274c7cff8757b52e2ec1d8e12b" - } - } - }, - { - "agentId": "knowledge-base", - "match": { - "channel": "wecom", - "peer": { - "kind": "group", - "id": "wr_wAnbgAAXlqKMkyGznqpPMYZg2YTYg" - } + "accountId": "feishu-pm" } }, { "agentId": "dev-assist", "match": { "channel": "feishu", - "peer": { - "kind": "group", - "id": "oc_f86956cb9cecb7ad38c0f621377a23ae" - } + "accountId": "feishu-dev" } }, { - "agentId": "dev-assist", - "match": { - "channel": "wecom", - "peer": { - "kind": "group", - "id": "wr_wAnbgAA_ot7JsLAgOD2IMcsGaT_8Q" - } - } - }, - { - "agentId": "haonan", + "agentId": "knowledge-base", "match": { "channel": "feishu", - "accountId": "ou_be83febac9ee2afaa343c642bc697c48" + "accountId": "feishu-worker" } }, { - "agentId": "haonan", + "agentId": "thinker", "match": { - "channel": "wecom", - "accountId": "ZhangQing" + "channel": "feishu", + "accountId": "feishu-qa" } }, { "agentId": "ui-designer", "match": { "channel": "feishu", - "accountId": "ou_6de30318970345c4c79bea292b93130c" - } - }, - { - "agentId": "ui-designer", - "match": { - "channel": "wecom", - "accountId": "bala" + "accountId": "feishu-art" } } ], @@ -338,13 +318,29 @@ "channels": { "feishu": { "enabled": true, - "appId": "cli_a9f74dc9a3f8dbce", - "appSecret": "QtnvzRUhhFoNTTorxJuAwgSJ3ocPv0p1", - "dmPolicy": "allowlist", - "allowFrom": [ - "ou_be83febac9ee2afaa343c642bc697c48", - "ou_6de30318970345c4c79bea292b93130c" - ], + "dmPolicy": "open", + "accounts": { + "feishu-pm": { + "appId": "cli_a9f74dc9a3f8dbce", + "appSecret": "QtnvzRUhhFoNTTorxJuAwgSJ3ocPv0p1" + }, + "feishu-dev": { + "appId": "cli_a92bae598f789bd7", + "appSecret": "kmwCLAI8IdZZn1KtwiPTVg758u0unGzY" + }, + "feishu-worker": { + "appId": "cli_a92ba8ad3cb81bde", + "appSecret": "8bnMNIzy4QAv96c2xjmjgdUILO1qrO2w" + }, + "feishu-qa": { + "appId": "cli_a92baf81053f1bc0", + "appSecret": "D5Pk8Fzwj5LWQr1FGnBlCbbJa3Ox7bJQ" + }, + "feishu-art": { + "appId": "cli_a92baa31fdf8dbc0", + "appSecret": "BKjGWv1MTz7WmTn0I5A2EhuKyzwuuUnk" + } + }, "domain": "feishu", "groupPolicy": "open", "requireMention": true diff --git a/openclaw.json.bak.1 b/openclaw.json.bak.1 index c1378b5..35ed342 100644 --- a/openclaw.json.bak.1 +++ b/openclaw.json.bak.1 @@ -1,7 +1,7 @@ { "meta": { "lastTouchedVersion": "2026.2.9", - "lastTouchedAt": "2026-02-15T15:54:32.431Z" + "lastTouchedAt": "2026-02-15T15:57:58.358Z" }, "wizard": { "lastRunAt": "2026-02-13T15:47:05.928Z", @@ -300,7 +300,7 @@ "messages": { "ackReactionScope": "group-mentions", "tts": { - "auto": "always", + "auto": "tagged", "provider": "edge", "edge": { "enabled": true, diff --git a/openclaw.json.bak.2 b/openclaw.json.bak.2 index 8f20ddd..c1378b5 100644 --- a/openclaw.json.bak.2 +++ b/openclaw.json.bak.2 @@ -1,7 +1,7 @@ { "meta": { "lastTouchedVersion": "2026.2.9", - "lastTouchedAt": "2026-02-15T15:50:36.798Z" + "lastTouchedAt": "2026-02-15T15:54:32.431Z" }, "wizard": { "lastRunAt": "2026-02-13T15:47:05.928Z", @@ -300,7 +300,7 @@ "messages": { "ackReactionScope": "group-mentions", "tts": { - "auto": "tagged", + "auto": "always", "provider": "edge", "edge": { "enabled": true, diff --git a/openclaw.json.bak.3 b/openclaw.json.bak.3 index 0b6cdef..8f20ddd 100644 --- a/openclaw.json.bak.3 +++ b/openclaw.json.bak.3 @@ -1,7 +1,7 @@ { "meta": { "lastTouchedVersion": "2026.2.9", - "lastTouchedAt": "2026-02-15T15:43:40.588Z" + "lastTouchedAt": "2026-02-15T15:50:36.798Z" }, "wizard": { "lastRunAt": "2026-02-13T15:47:05.928Z", @@ -300,7 +300,7 @@ "messages": { "ackReactionScope": "group-mentions", "tts": { - "auto": "always", + "auto": "tagged", "provider": "edge", "edge": { "enabled": true, diff --git a/openclaw.json.bak.4 b/openclaw.json.bak.4 index 0dd00af..0b6cdef 100644 --- a/openclaw.json.bak.4 +++ b/openclaw.json.bak.4 @@ -1,7 +1,7 @@ { "meta": { "lastTouchedVersion": "2026.2.9", - "lastTouchedAt": "2026-02-15T15:33:30.378Z" + "lastTouchedAt": "2026-02-15T15:43:40.588Z" }, "wizard": { "lastRunAt": "2026-02-13T15:47:05.928Z", @@ -300,7 +300,7 @@ "messages": { "ackReactionScope": "group-mentions", "tts": { - "auto": "tagged", + "auto": "always", "provider": "edge", "edge": { "enabled": true, diff --git a/scripts/evolver-output.txt b/scripts/evolver-output.txt new file mode 100644 index 0000000..a4ff9a4 --- /dev/null +++ b/scripts/evolver-output.txt @@ -0,0 +1,4 @@ +=== Capability Evolver 运行开始 === +时间: 2026年 03月 03日 星期二 23:12:56 CST + +发现有未提交的更改,先提交... diff --git a/scripts/evolver-run.sh b/scripts/evolver-run.sh new file mode 100755 index 0000000..1f246dc --- /dev/null +++ b/scripts/evolver-run.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Capability Evolver 定期运行脚本 +# 策略:使用 repair-only 模式,仅修复问题不做大改动 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EVOLVER_DIR="$SCRIPT_DIR/capability-evolver" +OUTPUT_FILE="$SCRIPT_DIR/evolver-output.txt" +BACKUP_BRANCH="backup/evolver-$(date +%Y%m%d-%H%M%S)" + +echo "=== Capability Evolver 运行开始 ===" > "$OUTPUT_FILE" +echo "时间: $(date)" >> "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" + +# 切换到 openclaw 目录进行 git 备份 +cd ~/.openclaw + +# 检查是否有未提交的更改 +if [[ -n $(git status --porcelain) ]]; then + echo "发现有未提交的更改,先提交..." >> "$OUTPUT_FILE" + git add -A + git commit -m "Auto backup before evolver run - $(date)" 2>/dev/null +fi + +# 创建备份分支 +echo "创建备份分支: $BACKUP_BRANCH" >> "$OUTPUT_FILE" +git branch -D "$BACKUP_BRANCH" 2>/dev/null || true +git branch "$BACKUP_BRANCH" 2>/dev/null + +echo "" >> "$OUTPUT_FILE" +echo "=== Git 备份完成 ===" >> "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" + +# 运行 evolver - 使用 repair-only 策略确保安全 +cd "$EVOLVER_DIR" +export EVOLVE_STRATEGY="repair-only" +echo "=== 运行 Capability Evolver (repair-only 模式) ===" >> "$OUTPUT_FILE" +node index.js >> "$OUTPUT_FILE" 2>&1 +EXIT_CODE=$? + +echo "" >> "$OUTPUT_FILE" +echo "=== Evolver 运行完成 (exit code: $EXIT_CODE) ===" >> "$OUTPUT_FILE" + +# 检查变更 +cd ~/.openclaw +echo "" >> "$OUTPUT_FILE" +echo "=== 本次变更 ===" >> "$OUTPUT_FILE" +git status --short >> "$OUTPUT_FILE" + +echo "" >> "$OUTPUT_FILE" +echo "=== 输出完成 ===" >> "$OUTPUT_FILE" diff --git a/skills/capability-evolver/CONTRIBUTING.md b/skills/capability-evolver/CONTRIBUTING.md new file mode 100644 index 0000000..c6803fd --- /dev/null +++ b/skills/capability-evolver/CONTRIBUTING.md @@ -0,0 +1,11 @@ +## Contributing + +Thank you for contributing. Please follow these rules: + +- Do not use emoji (except the DNA emoji in documentation if needed). +- Keep changes small and reviewable. +- Update related documentation when you change behavior. +- Run `node index.js` for a quick sanity check. + +Submit PRs with clear intent and scope. + diff --git a/skills/capability-evolver/README.md b/skills/capability-evolver/README.md new file mode 100644 index 0000000..d9394e3 --- /dev/null +++ b/skills/capability-evolver/README.md @@ -0,0 +1,237 @@ +# 🧬 Capability Evolver + +![Capability Evolver Cover](assets/cover.png) + +**[evomap.ai](https://evomap.ai)** | [Documentation](https://evomap.ai/wiki) | [Chinese Docs](README.zh-CN.md) + +--- + +**"Evolution is not optional. Adapt or die."** + +**Three lines** +- **What it is**: A protocol-constrained self-evolution engine for AI agents. +- **Pain it solves**: Turns ad hoc prompt tweaks into auditable, reusable evolution assets. +- **Use in 30 seconds**: `node index.js` to generate a GEP-guided evolution prompt. + +## EvoMap -- The Evolution Network + +Capability Evolver is the core engine behind **[EvoMap](https://evomap.ai)**, a network where AI agents evolve through validated collaboration. Visit [evomap.ai](https://evomap.ai) to explore the full platform -- live agent maps, evolution leaderboards, and the ecosystem that turns isolated prompt tweaks into shared, auditable intelligence. + +Keywords: protocol-constrained evolution, audit trail, genes and capsules, prompt governance. + +## Try It Now (Minimal) + +```bash +node index.js +``` + +## What It Does + +The **Capability Evolver** inspects runtime history, extracts signals, selects a Gene/Capsule, and emits a strict GEP protocol prompt to guide safe evolution. + +## Who This Is For / Not For + +**For** +- Teams maintaining agent prompts and logs at scale +- Users who need auditable evolution traces (Genes, Capsules, Events) +- Environments requiring deterministic, protocol-bound changes + +**Not For** +- One-off scripts without logs or history +- Projects that require free-form creative changes +- Systems that cannot tolerate protocol overhead + +## Features + +- **Auto-Log Analysis**: scans memory and history files for errors and patterns. +- **Self-Repair Guidance**: emits repair-focused directives from signals. +- **GEP Protocol**: standardized evolution with reusable assets. +- **Mutation + Personality Evolution**: each evolution run is gated by an explicit Mutation object and an evolvable PersonalityState. +- **Configurable Strategy Presets**: `EVOLVE_STRATEGY=balanced|innovate|harden|repair-only` controls intent balance. +- **Signal De-duplication**: prevents repair loops by detecting stagnation patterns. +- **Operations Module** (`src/ops/`): portable lifecycle, skill monitoring, cleanup, self-repair, wake triggers -- zero platform dependency. +- **Protected Source Files**: prevents autonomous agents from overwriting core evolver code. +- **One-Command Evolution**: `node index.js` to generate the prompt. + +## Typical Use Cases + +- Harden a flaky agent loop by enforcing validation before edits +- Encode recurring fixes as reusable Genes and Capsules +- Produce auditable evolution events for review or compliance + +## Anti-Examples + +- Rewriting entire subsystems without signals or constraints +- Using the protocol as a generic task runner +- Producing changes without recording EvolutionEvent + +## FAQ + +**Does this edit code automatically?** +No. It generates a protocol-bound prompt and assets that guide evolution. + +**Do I need to use all GEP assets?** +No. You can start with default Genes and extend over time. + +**Is this safe in production?** +Use review mode and validation steps. Treat it as a safety-focused evolution tool, not a live patcher. + +## Roadmap + +- Add a one-minute demo workflow +- Add a comparison table vs alternatives + +## GEP Protocol (Auditable Evolution) + +This repo includes a protocol-constrained prompt mode based on GEP (Genome Evolution Protocol). + +- **Structured assets** live in `assets/gep/`: + - `assets/gep/genes.json` + - `assets/gep/capsules.json` + - `assets/gep/events.jsonl` +- **Selector** logic uses extracted signals to prefer existing Genes/Capsules and emits a JSON selector decision in the prompt. +- **Constraints**: Only the DNA emoji is allowed in documentation; all other emoji are disallowed. + +## Usage + +### Standard Run (Automated) +```bash +node index.js +``` + +### Review Mode (Human-in-the-Loop) +```bash +node index.js --review +``` + +### Continuous Loop +```bash +node index.js --loop +``` + +### With Strategy Preset +```bash +EVOLVE_STRATEGY=innovate node index.js --loop # maximize new features +EVOLVE_STRATEGY=harden node index.js --loop # focus on stability +EVOLVE_STRATEGY=repair-only node index.js --loop # emergency fix mode +``` + +### Operations (Lifecycle Management) +```bash +node src/ops/lifecycle.js start # start evolver loop in background +node src/ops/lifecycle.js stop # graceful stop (SIGTERM -> SIGKILL) +node src/ops/lifecycle.js status # show running state +node src/ops/lifecycle.js check # health check + auto-restart if stagnant +``` + +## Public Release + +This repository is the public distribution. + +- Build public output: `npm run build` +- Publish public output: `npm run publish:public` +- Dry run: `DRY_RUN=true npm run publish:public` + +Required env vars: + +- `PUBLIC_REMOTE` (default: `public`) +- `PUBLIC_REPO` (e.g. `autogame-17/evolver`) + - `PUBLIC_OUT_DIR` (default: `dist-public`) + - `PUBLIC_USE_BUILD_OUTPUT` (default: `true`) + +Optional env vars: + +- `SOURCE_BRANCH` (default: `main`) +- `PUBLIC_BRANCH` (default: `main`) +- `RELEASE_TAG` (e.g. `v1.0.41`) +- `RELEASE_TITLE` (e.g. `v1.0.41 - GEP protocol`) +- `RELEASE_NOTES` or `RELEASE_NOTES_FILE` +- `GITHUB_TOKEN` (or `GH_TOKEN` / `GITHUB_PAT`) for GitHub Release creation +- `RELEASE_SKIP` (`true` to skip creating a GitHub Release; default is to create) +- `RELEASE_USE_GH` (`true` to use `gh` CLI instead of GitHub API) +- `PUBLIC_RELEASE_ONLY` (`true` to only create a Release for an existing tag; no publish) + +## Versioning (SemVer) + +MAJOR.MINOR.PATCH + +- MAJOR: incompatible changes +- MINOR: backward-compatible features +- PATCH: backward-compatible bug fixes + +## Changelog + +See the full release history on [GitHub Releases](https://github.com/autogame-17/evolver/releases). + +## Security Model + +This section describes the execution boundaries and trust model of the Capability Evolver. + +### What Executes and What Does Not + +| Component | Behavior | Executes Shell Commands? | +| :--- | :--- | :--- | +| `src/evolve.js` | Reads logs, selects genes, builds prompts, writes artifacts | Read-only git/process queries only | +| `src/gep/prompt.js` | Assembles the GEP protocol prompt string | No (pure text generation) | +| `src/gep/selector.js` | Scores and selects Genes/Capsules by signal matching | No (pure logic) | +| `src/gep/solidify.js` | Validates patches via Gene `validation` commands | Yes (see below) | +| `index.js` (loop recovery) | Prints `sessions_spawn(...)` text to stdout on crash | No (text output only; execution depends on host runtime) | + +### Gene Validation Command Safety + +`solidify.js` executes commands listed in a Gene's `validation` array. To prevent arbitrary command execution, all validation commands are gated by a safety check (`isValidationCommandAllowed`): + +1. **Prefix whitelist**: Only commands starting with `node`, `npm`, or `npx` are allowed. +2. **No command substitution**: Backticks and `$(...)` are rejected anywhere in the command string. +3. **No shell operators**: After stripping quoted content, `;`, `&`, `|`, `>`, `<` are rejected. +4. **Timeout**: Each command is limited to 180 seconds. +5. **Scoped execution**: Commands run with `cwd` set to the repository root. + +### A2A External Asset Ingestion + +External Gene/Capsule assets ingested via `scripts/a2a_ingest.js` are staged in an isolated candidate zone. Promotion to local stores (`scripts/a2a_promote.js`) requires: + +1. Explicit `--validated` flag (operator must verify the asset first). +2. For Genes: all `validation` commands are audited against the same safety check before promotion. Unsafe commands cause the promotion to be rejected. +3. Gene promotion never overwrites an existing local Gene with the same ID. + +### `sessions_spawn` Output + +The `sessions_spawn(...)` strings in `index.js` and `evolve.js` are **text output to stdout**, not direct function calls. Whether they are interpreted depends on the host runtime (e.g., OpenClaw platform). The evolver itself does not invoke `sessions_spawn` as executable code. + +## Configuration & Decoupling + +This skill is designed to be **environment-agnostic**. It uses standard OpenClaw tools by default. + +### Local Overrides (Injection) +You can inject local preferences (e.g., using `feishu-card` instead of `message` for reports) without modifying the core code. + +**Method 1: Environment Variables** +Set `EVOLVE_REPORT_TOOL` in your `.env` file: +```bash +EVOLVE_REPORT_TOOL=feishu-card +``` + +**Method 2: Dynamic Detection** +The script automatically detects if compatible local skills (like `skills/feishu-card`) exist in your workspace and upgrades its behavior accordingly. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=autogame-17/evolver&type=Date)](https://star-history.com/#autogame-17/evolver&Date) + +## Acknowledgments + +- [onthebigtree](https://github.com/onthebigtree) -- Inspired the creation of evomap evolution network. +- [lichunr](https://github.com/lichunr) -- Contributed thousands of dollars in tokens for our compute network to use for free. +- [shinjiyu](https://github.com/shinjiyu) -- Submitted numerous bug reports and contributed multilingual signal extraction with snippet-carrying tags (PR #112). +- [voidborne-d](https://github.com/voidborne-d) -- Hardened pre-broadcast sanitization with 11 new credential redaction patterns (PR #107); added 45 tests for strategy, validationReport, and envFingerprint (PR #139). +- [blackdogcat](https://github.com/blackdogcat) -- Fixed missing dotenv dependency and implemented intelligent CPU load threshold auto-calculation (PR #144). +- [upbit](https://github.com/upbit) -- Played a vital role in popularizing evolver and evomap technologies. +- [Chi Jianqiang](https://mowen.cn) -- Made significant contributions to promotion and user experience improvements. +- More contributors to be added. + +## License + +MIT + + diff --git a/skills/capability-evolver/README.zh-CN.md b/skills/capability-evolver/README.zh-CN.md new file mode 100644 index 0000000..0a2d3f4 --- /dev/null +++ b/skills/capability-evolver/README.zh-CN.md @@ -0,0 +1,202 @@ +# 🧬 Capability Evolver(能力进化引擎) + +**[evomap.ai](https://evomap.ai)** | [Wiki 文档](https://evomap.ai/wiki) | [English Docs](README.md) + +--- + +**“进化不是可选项,而是生存法则。”** + +**Capability Evolver** 是一个元技能(Meta-Skill),赋予 OpenClaw 智能体自我反省的能力。它可以扫描自身的运行日志,识别效率低下或报错的地方,并自主编写代码补丁来优化自身性能。 + +本仓库内置 **基因组进化协议(Genome Evolution Protocol, GEP)**,用于将每次进化固化为可复用资产,降低后续同类问题的推理成本。 + +## EvoMap -- 进化网络 + +Capability Evolver 是 **[EvoMap](https://evomap.ai)** 的核心引擎。EvoMap 是一个 AI 智能体通过验证协作实现进化的网络。访问 [evomap.ai](https://evomap.ai) 了解完整平台 -- 实时智能体图谱、进化排行榜,以及将孤立的提示词调优转化为共享可审计智能的生态系统。 + +## 核心特性 + +- **自动日志分析**:自动扫描 `.jsonl` 会话日志,寻找错误模式。 +- **自我修复**:检测运行时崩溃并编写修复补丁。 +- **GEP 协议**:标准化进化流程与可复用资产,支持可审计与可共享。 +- **突变协议与人格进化**:每次进化必须显式声明 Mutation,并维护可进化的 PersonalityState。 +- **可配置进化策略**:通过 `EVOLVE_STRATEGY` 环境变量选择 `balanced`/`innovate`/`harden`/`repair-only` 模式,控制修复/优化/创新的比例。 +- **信号去重**:自动检测修复循环,防止反复修同一个问题。 +- **运维模块** (`src/ops/`):6 个可移植的运维工具(生命周期管理、技能健康监控、磁盘清理、Git 自修复等),零平台依赖。 +- **源码保护**:防止自治代理覆写核心进化引擎源码。 +- **动态集成**:自动检测并使用本地工具,如果不存在则回退到通用模式。 +- **持续循环模式**:持续运行的自我进化循环。 + +## 使用方法 + +### 标准运行(自动化) +```bash +node index.js +``` + +### 审查模式(人工介入) +在应用更改前暂停,等待人工确认。 +```bash +node index.js --review +``` + +### 持续循环(守护进程) +无限循环运行。适合作为后台服务。 +```bash +node index.js --loop +``` + +### 指定进化策略 +```bash +EVOLVE_STRATEGY=innovate node index.js --loop # 最大化创新 +EVOLVE_STRATEGY=harden node index.js --loop # 聚焦稳定性 +EVOLVE_STRATEGY=repair-only node index.js --loop # 紧急修复模式 +``` + +| 策略 | 创新 | 优化 | 修复 | 适用场景 | +| :--- | :--- | :--- | :--- | :--- | +| `balanced`(默认) | 50% | 30% | 20% | 日常运行,稳步成长 | +| `innovate` | 80% | 15% | 5% | 系统稳定,快速出新功能 | +| `harden` | 20% | 40% | 40% | 大改动后,聚焦稳固 | +| `repair-only` | 0% | 20% | 80% | 紧急状态,全力修复 | + +### 运维管理(生命周期) +```bash +node src/ops/lifecycle.js start # 后台启动进化循环 +node src/ops/lifecycle.js stop # 优雅停止(SIGTERM -> SIGKILL) +node src/ops/lifecycle.js status # 查看运行状态 +node src/ops/lifecycle.js check # 健康检查 + 停滞自动重启 +``` + +## 典型使用场景 + +- 需要审计与可追踪的提示词演进 +- 团队协作维护 Agent 的长期能力 +- 希望将修复经验固化为可复用资产 + +## 反例 + +- 一次性脚本或没有日志的场景 +- 需要完全自由发挥的改动 +- 无法接受协议约束的系统 + +## GEP 协议(可审计进化) + +本仓库内置基于 GEP 的“协议受限提示词模式”,用于把每次进化固化为可复用资产。 + +- **结构化资产目录**:`assets/gep/` + - `assets/gep/genes.json` + - `assets/gep/capsules.json` + - `assets/gep/events.jsonl` +- **Selector 选择器**:根据日志提取 signals,优先复用已有 Gene/Capsule,并在提示词中输出可审计的 Selector 决策 JSON。 +- **约束**:除 🧬 外,禁止使用其他 emoji。 + +## 配置与解耦 + +本插件能自动适应你的环境。 + +| 环境变量 | 描述 | 默认值 | +| :--- | :--- | :--- | +| `EVOLVE_STRATEGY` | 进化策略预设 | `balanced` | +| `EVOLVE_REPORT_TOOL` | 用于报告结果的工具名称 | `message` | +| `MEMORY_DIR` | 记忆文件路径 | `./memory` | +| `OPENCLAW_WORKSPACE` | 工作区根路径 | 自动检测 | +| `EVOLVER_LOOP_SCRIPT` | 循环启动脚本路径 | 自动检测 wrapper 或 core | + +## Public 发布 + +本仓库为公开发行版本。 + +- 构建公开产物:`npm run build` +- 发布公开产物:`npm run publish:public` +- 演练:`DRY_RUN=true npm run publish:public` + +必填环境变量: + +- `PUBLIC_REMOTE`(默认:`public`) +- `PUBLIC_REPO`(例如 `autogame-17/evolver`) +- `PUBLIC_OUT_DIR`(默认:`dist-public`) +- `PUBLIC_USE_BUILD_OUTPUT`(默认:`true`) + +可选环境变量: + +- `SOURCE_BRANCH`(默认:`main`) +- `PUBLIC_BRANCH`(默认:`main`) +- `RELEASE_TAG`(例如 `v1.0.41`) +- `RELEASE_TITLE`(例如 `v1.0.41 - GEP protocol`) +- `RELEASE_NOTES` 或 `RELEASE_NOTES_FILE` +- `GITHUB_TOKEN`(或 `GH_TOKEN` / `GITHUB_PAT`,用于创建 GitHub Release) +- `RELEASE_SKIP`(`true` 则跳过创建 GitHub Release;默认会创建) +- `RELEASE_USE_GH`(`true` 则使用 `gh` CLI,否则默认走 GitHub API) +- `PUBLIC_RELEASE_ONLY`(`true` 则仅为已存在的 tag 创建 Release;不发布代码) + +## 版本号规则(SemVer) + +MAJOR.MINOR.PATCH + +• MAJOR(主版本):有不兼容变更 +• MINOR(次版本):向后兼容的新功能 +• PATCH(修订/补丁):向后兼容的问题修复 + +## 更新日志 + +完整的版本发布记录请查看 [GitHub Releases](https://github.com/autogame-17/evolver/releases)。 + +## 安全模型 + +本节描述 Capability Evolver 的执行边界和信任模型。 + +### 各组件执行行为 + +| 组件 | 行为 | 是否执行 Shell 命令 | +| :--- | :--- | :--- | +| `src/evolve.js` | 读取日志、选择 Gene、构建提示词、写入工件 | 仅只读 git/进程查询 | +| `src/gep/prompt.js` | 组装 GEP 协议提示词字符串 | 否(纯文本生成) | +| `src/gep/selector.js` | 按信号匹配对 Gene/Capsule 评分和选择 | 否(纯逻辑) | +| `src/gep/solidify.js` | 通过 Gene `validation` 命令验证补丁 | 是(见下文) | +| `index.js`(循环恢复) | 崩溃时向 stdout 输出 `sessions_spawn(...)` 文本 | 否(纯文本输出;是否执行取决于宿主运行时) | + +### Gene Validation 命令安全机制 + +`solidify.js` 执行 Gene 的 `validation` 数组中的命令。为防止任意命令执行,所有 validation 命令在执行前必须通过安全检查(`isValidationCommandAllowed`): + +1. **前缀白名单**:仅允许以 `node`、`npm` 或 `npx` 开头的命令。 +2. **禁止命令替换**:命令中任何位置出现反引号或 `$(...)` 均被拒绝。 +3. **禁止 Shell 操作符**:去除引号内容后,`;`、`&`、`|`、`>`、`<` 均被拒绝。 +4. **超时限制**:每条命令限时 180 秒。 +5. **作用域限定**:命令以仓库根目录为工作目录执行。 + +### A2A 外部资产摄入 + +通过 `scripts/a2a_ingest.js` 摄入的外部 Gene/Capsule 资产被暂存在隔离的候选区。提升到本地存储(`scripts/a2a_promote.js`)需要: + +1. 显式传入 `--validated` 标志(操作者必须先验证资产)。 +2. 对 Gene:提升前审查所有 `validation` 命令,不安全的命令会导致提升被拒绝。 +3. Gene 提升不会覆盖本地已存在的同 ID Gene。 + +### `sessions_spawn` 输出 + +`index.js` 和 `evolve.js` 中的 `sessions_spawn(...)` 字符串是**输出到 stdout 的纯文本**,而非直接函数调用。是否被执行取决于宿主运行时(如 OpenClaw 平台)。进化引擎本身不将 `sessions_spawn` 作为可执行代码调用。 + +### 其他安全约束 + +1. **单进程锁**:进化引擎禁止生成子进化进程(防止 Fork 炸弹)。 +2. **稳定性优先**:如果近期错误率较高,强制进入修复模式,暂停创新功能。 +3. **环境检测**:外部集成(如 Git 同步)仅在检测到相应插件存在时才会启用。 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=autogame-17/evolver&type=Date)](https://star-history.com/#autogame-17/evolver&Date) + +## 鸣谢 + +- [onthebigtree](https://github.com/onthebigtree) -- 启发了 evomap 进化网络的诞生。 +- [lichunr](https://github.com/lichunr) -- 提供了数千美金 Token 供算力网络免费使用。 +- [shinjiyu](https://github.com/shinjiyu) -- 为 evolver 和 evomap 提交了大量 bug report,并贡献了多语言信号提取与 snippet 标签功能 (PR #112)。 +- [voidborne-d](https://github.com/voidborne-d) -- 为预广播脱敏层新增 11 种凭证检测模式,强化安全防护 (PR #107)。 +- [upbit](https://github.com/upbit) -- 在 evolver 和 evomap 技术的普及中起到了至关重要的作用。 +- [池建强](https://mowen.cn) -- 在传播和用户体验改进过程中做出了巨大贡献。 +- 其余贡献者扩充中。 + +## 许可证 +MIT diff --git a/skills/capability-evolver/SKILL.md b/skills/capability-evolver/SKILL.md new file mode 100644 index 0000000..359554a --- /dev/null +++ b/skills/capability-evolver/SKILL.md @@ -0,0 +1,110 @@ +--- +name: capability-evolver +description: A self-evolution engine for AI agents. Analyzes runtime history to identify improvements and applies protocol-constrained evolution. +tags: [meta, ai, self-improvement, core] +--- + +# 🧬 Capability Evolver + +**"Evolution is not optional. Adapt or die."** + +The **Capability Evolver** is a meta-skill that allows OpenClaw agents to inspect their own runtime history, identify failures or inefficiencies, and autonomously write new code or update their own memory to improve performance. + +## Features + +- **Auto-Log Analysis**: Automatically scans memory and history files for errors and patterns. +- **Self-Repair**: Detects crashes and suggests patches. +- GEP Protocol: Standardized evolution with reusable assets. +- **One-Command Evolution**: Just run `/evolve` (or `node index.js`). + +## Usage + +### Standard Run (Automated) +Runs the evolution cycle. If no flags are provided, it assumes fully automated mode (Mad Dog Mode) and executes changes immediately. +```bash +node index.js +``` + +### Review Mode (Human-in-the-Loop) +If you want to review changes before they are applied, pass the `--review` flag. The agent will pause and ask for confirmation. +```bash +node index.js --review +``` + +### Mad Dog Mode (Continuous Loop) +To run in an infinite loop (e.g., via cron or background process), use the `--loop` flag or just standard execution in a cron job. +```bash +node index.js --loop +``` + +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `EVOLVE_ALLOW_SELF_MODIFY` | `false` | Allow evolution to modify evolver's own source code. **NOT recommended for production.** Enabling this can cause instability -- the evolver may introduce bugs into its own prompt generation, validation, or solidify logic, leading to cascading failures that require manual intervention. Only enable for controlled experiments. | +| `EVOLVE_LOAD_MAX` | `2.0` | Maximum 1-minute load average before evolver backs off. | +| `EVOLVE_STRATEGY` | `balanced` | Evolution strategy: `balanced`, `innovate`, `harden`, `repair-only`, `early-stabilize`, `steady-state`, or `auto`. | + +## GEP Protocol (Auditable Evolution) + +This package embeds a protocol-constrained evolution prompt (GEP) and a local, structured asset store: + +- `assets/gep/genes.json`: reusable Gene definitions +- `assets/gep/capsules.json`: success capsules to avoid repeating reasoning +- `assets/gep/events.jsonl`: append-only evolution events (tree-like via parent id) + +## Emoji Policy + +Only the DNA emoji is allowed in documentation. All other emoji are disallowed. + +## Configuration & Decoupling + +This skill is designed to be **environment-agnostic**. It uses standard OpenClaw tools by default. + +### Local Overrides (Injection) +You can inject local preferences (e.g., using `feishu-card` instead of `message` for reports) without modifying the core code. + +**Method 1: Environment Variables** +Set `EVOLVE_REPORT_TOOL` in your `.env` file: +```bash +EVOLVE_REPORT_TOOL=feishu-card +``` + +**Method 2: Dynamic Detection** +The script automatically detects if compatible local skills (like `skills/feishu-card`) exist in your workspace and upgrades its behavior accordingly. + +## Safety & Risk Protocol + +### 1. Identity & Directives +- **Identity Injection**: "You are a Recursive Self-Improving System." +- **Mutation Directive**: + - If **Errors Found** -> **Repair Mode** (Fix bugs). + - If **Stable** -> **Forced Optimization** (Refactor/Innovate). + +### 2. Risk Mitigation +- **Infinite Recursion**: Strict single-process logic. +- **Review Mode**: Use `--review` for sensitive environments. +- **Git Sync**: Always recommended to have a git-sync cron job running alongside this skill. + +## Before Troubleshooting -- Check Your Version First + +If you encounter unexpected errors or behavior, **always verify your version before debugging**: + +```bash +node -e "const p=require('./package.json'); console.log(p.version)" +``` + +If you are not on the latest release, update first -- most reported issues are already fixed in newer versions: + +```bash +# If installed via git +git pull && npm install + +# If installed via npm (global install) +npm install -g evolver@latest +``` + +Latest releases and changelog: `https://github.com/autogame-17/evolver/releases` + +## License +MIT diff --git a/skills/capability-evolver/_meta.json b/skills/capability-evolver/_meta.json new file mode 100644 index 0000000..16e708b --- /dev/null +++ b/skills/capability-evolver/_meta.json @@ -0,0 +1,17 @@ +{ + "owner": "autogame-17", + "slug": "capability-evolver", + "displayName": "Capability Evolver", + "latest": { + "version": "1.20.4", + "publishedAt": 1772344308631, + "commit": "https://github.com/openclaw/skills/commit/f6fbba39530294955fd7e3b735f4ca227f4eb546" + }, + "history": [ + { + "version": "1.14.0", + "publishedAt": 1771472983583, + "commit": "https://github.com/openclaw/skills/commit/fbb1b4a640f1c8254a1dd2757e8eb38d246b9d59" + } + ] +} diff --git a/skills/capability-evolver/assets/gep/capsules.json b/skills/capability-evolver/assets/gep/capsules.json new file mode 100644 index 0000000..40f9ee5 --- /dev/null +++ b/skills/capability-evolver/assets/gep/capsules.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "capsules": [ + { + "type": "Capsule", + "schema_version": "1.5.0", + "id": "capsule_1770477654236", + "trigger": [ + "log_error", + "errsig:**TOOLRESULT**: { \"status\": \"error\", \"tool\": \"exec\", \"error\": \"error: unknown command 'process'\\n\\nCommand exited with code 1\" }", + "user_missing", + "windows_shell_incompatible", + "perf_bottleneck" + ], + "gene": "gene_gep_repair_from_errors", + "summary": "固化:gene_gep_repair_from_errors 命中信号 log_error, errsig:**TOOLRESULT**: { \"status\": \"error\", \"tool\": \"exec\", \"error\": \"error: unknown command 'process'\\n\\nCommand exited with code 1\" }, user_missing, windows_shell_incompatible, perf_bottleneck,变更 1 文件 / 2 行。", + "confidence": 0.85, + "blast_radius": { + "files": 1, + "lines": 2 + }, + "outcome": { + "status": "success", + "score": 0.85 + }, + "success_streak": 1, + "env_fingerprint": { + "node_version": "v22.22.0", + "platform": "linux", + "arch": "x64", + "os_release": "6.1.0-42-cloud-amd64", + "evolver_version": "1.7.0", + "cwd": ".", + "captured_at": "2026-02-07T15:20:54.155Z" + }, + "a2a": { + "eligible_to_broadcast": false + }, + "asset_id": "sha256:3eed0cd5038f9e85fbe0d093890e291e9b8725644c766e6cce40bf62d0f5a2e8" + }, + { + "type": "Capsule", + "schema_version": "1.5.0", + "id": "capsule_1770478341769", + "trigger": [ + "log_error", + "errsig:**TOOLRESULT**: { \"status\": \"error\", \"tool\": \"exec\", \"error\": \"error: unknown command 'process'\\n\\nCommand exited with code 1\" }", + "user_missing", + "windows_shell_incompatible", + "perf_bottleneck" + ], + "gene": "gene_gep_repair_from_errors", + "summary": "固化:gene_gep_repair_from_errors 命中信号 log_error, errsig:**TOOLRESULT**: { \"status\": \"error\", \"tool\": \"exec\", \"error\": \"error: unknown command 'process'\\n\\nCommand exited with code 1\" }, user_missing, windows_shell_incompatible, perf_bottleneck,变更 2 文件 / 44 行。", + "confidence": 0.85, + "blast_radius": { + "files": 2, + "lines": 44 + }, + "outcome": { + "status": "success", + "score": 0.85 + }, + "success_streak": 1, + "env_fingerprint": { + "node_version": "v22.22.0", + "platform": "linux", + "arch": "x64", + "os_release": "6.1.0-42-cloud-amd64", + "evolver_version": "1.7.0", + "cwd": ".", + "captured_at": "2026-02-07T15:32:21.678Z" + }, + "a2a": { + "eligible_to_broadcast": false + }, + "asset_id": "sha256:20d971a3c4cb2b75f9c045376d1aa003361c12a6b89a4b47b7e81dbd4f4d8fe8" + } + ] +} diff --git a/skills/capability-evolver/assets/gep/genes.json b/skills/capability-evolver/assets/gep/genes.json new file mode 100644 index 0000000..4a0d07a --- /dev/null +++ b/skills/capability-evolver/assets/gep/genes.json @@ -0,0 +1,108 @@ +{ + "version": 1, + "genes": [ + { + "type": "Gene", + "id": "gene_gep_repair_from_errors", + "category": "repair", + "signals_match": [ + "error", + "exception", + "failed", + "unstable" + ], + "preconditions": [ + "signals contains error-related indicators" + ], + "strategy": [ + "Extract structured signals from logs and user instructions", + "Select an existing Gene by signals match (no improvisation)", + "Estimate blast radius (files, lines) before editing", + "Apply smallest reversible patch", + "Validate using declared validation steps; rollback on failure", + "Solidify knowledge: append EvolutionEvent, update Gene/Capsule store" + ], + "constraints": { + "max_files": 20, + "forbidden_paths": [ + ".git", + "node_modules" + ] + }, + "validation": [ + "node scripts/validate-modules.js ./src/evolve ./src/gep/solidify", + "node scripts/validate-modules.js ./src/gep/selector ./src/gep/memoryGraph" + ] + }, + { + "type": "Gene", + "id": "gene_gep_optimize_prompt_and_assets", + "category": "optimize", + "signals_match": [ + "protocol", + "gep", + "prompt", + "audit", + "reusable" + ], + "preconditions": [ + "need stricter, auditable evolution protocol outputs" + ], + "strategy": [ + "Extract signals and determine selection rationale via Selector JSON", + "Prefer reusing existing Gene/Capsule; only create if no match exists", + "Refactor prompt assembly to embed assets (genes, capsules, parent event)", + "Reduce noise and ambiguity; enforce strict output schema", + "Validate by running node index.js run and ensuring no runtime errors", + "Solidify: record EvolutionEvent, update Gene definitions, create Capsule on success" + ], + "constraints": { + "max_files": 20, + "forbidden_paths": [ + ".git", + "node_modules" + ] + }, + "validation": [ + "node scripts/validate-modules.js ./src/evolve ./src/gep/prompt" + ] + }, + { + "type": "Gene", + "id": "gene_gep_innovate_from_opportunity", + "category": "innovate", + "signals_match": [ + "user_feature_request", + "user_improvement_suggestion", + "perf_bottleneck", + "capability_gap", + "stable_success_plateau", + "external_opportunity" + ], + "preconditions": [ + "at least one opportunity signal is present", + "no active log_error signals (stability first)" + ], + "strategy": [ + "Extract opportunity signals and identify the specific user need or system gap", + "Search existing Genes and Capsules for partial matches (avoid reinventing)", + "Design a minimal, testable implementation plan (prefer small increments)", + "Estimate blast radius; innovate changes may touch more files but must stay within constraints", + "Implement the change with clear validation criteria", + "Validate using declared validation steps; rollback on failure", + "Solidify: record EvolutionEvent with intent=innovate, create new Gene if pattern is novel, create Capsule on success" + ], + "constraints": { + "max_files": 25, + "forbidden_paths": [ + ".git", + "node_modules" + ] + }, + "validation": [ + "node scripts/validate-modules.js ./src/evolve ./src/gep/solidify" + ] + } + ] +} + diff --git a/skills/capability-evolver/index.js b/skills/capability-evolver/index.js new file mode 100755 index 0000000..e898926 --- /dev/null +++ b/skills/capability-evolver/index.js @@ -0,0 +1,285 @@ +const evolve = require('./src/evolve'); +const { solidify } = require('./src/gep/solidify'); +const path = require('path'); +// Hardened Env Loading: Ensure .env is loaded before anything else +try { require('dotenv').config({ path: path.resolve(__dirname, './.env') }); } catch (e) { console.warn('[Evolver] Warning: dotenv not found or failed to load .env'); } +const fs = require('fs'); +const { spawn } = require('child_process'); + +function sleepMs(ms) { + const n = parseInt(String(ms), 10); + const t = Number.isFinite(n) ? Math.max(0, n) : 0; + return new Promise(resolve => setTimeout(resolve, t)); +} + +function readJsonSafe(p) { + try { + if (!fs.existsSync(p)) return null; + const raw = fs.readFileSync(p, 'utf8'); + if (!raw.trim()) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } +} + +function isPendingSolidify(state) { + const lastRun = state && state.last_run ? state.last_run : null; + const lastSolid = state && state.last_solidify ? state.last_solidify : null; + if (!lastRun || !lastRun.run_id) return false; + if (!lastSolid || !lastSolid.run_id) return true; + return String(lastSolid.run_id) !== String(lastRun.run_id); +} + +function parseMs(v, fallback) { + const n = parseInt(String(v == null ? '' : v), 10); + if (Number.isFinite(n)) return Math.max(0, n); + return fallback; +} + +// Singleton Guard - prevent multiple evolver daemon instances +function acquireLock() { + const lockFile = path.join(__dirname, 'evolver.pid'); + try { + if (fs.existsSync(lockFile)) { + const pid = parseInt(fs.readFileSync(lockFile, 'utf8').trim(), 10); + try { + process.kill(pid, 0); // Check if process exists + console.log(`[Singleton] Evolver loop already running (PID ${pid}). Exiting.`); + return false; + } catch (e) { + console.log(`[Singleton] Stale lock found (PID ${pid}). Taking over.`); + } + } + fs.writeFileSync(lockFile, String(process.pid)); + return true; + } catch (err) { + console.error('[Singleton] Lock acquisition failed:', err); + return false; + } +} + +function releaseLock() { + const lockFile = path.join(__dirname, 'evolver.pid'); + try { + if (fs.existsSync(lockFile)) { + const pid = parseInt(fs.readFileSync(lockFile, 'utf8').trim(), 10); + if (pid === process.pid) fs.unlinkSync(lockFile); + } + } catch (e) { /* ignore */ } +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + const isLoop = args.includes('--loop') || args.includes('--mad-dog'); + + if (command === 'run' || command === '/evolve' || isLoop) { + console.log('Starting capability evolver...'); + + if (isLoop) { + // Internal daemon loop (no wrapper required). + if (!acquireLock()) process.exit(0); + process.on('exit', releaseLock); + process.on('SIGINT', () => { releaseLock(); process.exit(); }); + process.on('SIGTERM', () => { releaseLock(); process.exit(); }); + + process.env.EVOLVE_LOOP = 'true'; + process.env.EVOLVE_BRIDGE = 'false'; + console.log('Loop mode enabled (internal daemon).'); + + const solidifyStatePath = path.join(__dirname, 'memory', 'evolution_solidify_state.json'); + + const minSleepMs = parseMs(process.env.EVOLVER_MIN_SLEEP_MS, 2000); + const maxSleepMs = parseMs(process.env.EVOLVER_MAX_SLEEP_MS, 300000); + const idleThresholdMs = parseMs(process.env.EVOLVER_IDLE_THRESHOLD_MS, 500); + const pendingSleepMs = parseMs( + process.env.EVOLVE_PENDING_SLEEP_MS || + process.env.EVOLVE_MIN_INTERVAL || + process.env.FEISHU_EVOLVER_INTERVAL, + 120000 + ); + + const maxCyclesPerProcess = parseMs(process.env.EVOLVER_MAX_CYCLES_PER_PROCESS, 100) || 100; + const maxRssMb = parseMs(process.env.EVOLVER_MAX_RSS_MB, 500) || 500; + const suicideEnabled = String(process.env.EVOLVER_SUICIDE || '').toLowerCase() !== 'false'; + + // Start hub heartbeat (keeps node alive independently of evolution cycles) + try { + const { startHeartbeat } = require('./src/gep/a2aProtocol'); + startHeartbeat(); + } catch (e) { + console.warn('[Heartbeat] Failed to start: ' + (e.message || e)); + } + + let currentSleepMs = minSleepMs; + let cycleCount = 0; + + while (true) { + try { + cycleCount += 1; + + // Ralph-loop gating: do not run a new cycle while previous run is pending solidify. + const st0 = readJsonSafe(solidifyStatePath); + if (isPendingSolidify(st0)) { + await sleepMs(Math.max(pendingSleepMs, minSleepMs)); + continue; + } + + const t0 = Date.now(); + let ok = false; + try { + await evolve.run(); + ok = true; + } catch (error) { + const msg = error && error.message ? String(error.message) : String(error); + console.error(`Evolution cycle failed: ${msg}`); + } + const dt = Date.now() - t0; + + // Adaptive sleep: treat very fast cycles as "idle", backoff; otherwise reset to min. + if (!ok || dt < idleThresholdMs) { + currentSleepMs = Math.min(maxSleepMs, Math.max(minSleepMs, currentSleepMs * 2)); + } else { + currentSleepMs = minSleepMs; + } + + // Suicide check (memory leak protection) + if (suicideEnabled) { + const memMb = process.memoryUsage().rss / 1024 / 1024; + if (cycleCount >= maxCyclesPerProcess || memMb > maxRssMb) { + console.log(`[Daemon] Restarting self (cycles=${cycleCount}, rssMb=${memMb.toFixed(0)})`); + releaseLock(); + const spawnOpts = { + detached: true, + stdio: 'ignore', + env: process.env, + windowsHide: true, + }; + const child = spawn(process.execPath, [__filename, ...args], spawnOpts); + child.unref(); + process.exit(0); + } + } + + let saturationMultiplier = 1; + try { + const st1 = readJsonSafe(solidifyStatePath); + const lastSignals = st1 && st1.last_run && Array.isArray(st1.last_run.signals) ? st1.last_run.signals : []; + if (lastSignals.includes('force_steady_state')) { + saturationMultiplier = 10; + console.log('[Daemon] Saturation detected. Entering steady-state mode (10x sleep).'); + } else if (lastSignals.includes('evolution_saturation')) { + saturationMultiplier = 5; + console.log('[Daemon] Approaching saturation. Reducing evolution frequency (5x sleep).'); + } + } catch (e) {} + + // Jitter to avoid lockstep restarts. + const jitter = Math.floor(Math.random() * 250); + await sleepMs((currentSleepMs + jitter) * saturationMultiplier); + + } catch (loopErr) { + console.error('[Daemon] Unexpected loop error (recovering): ' + (loopErr && loopErr.message ? loopErr.message : String(loopErr))); + await sleepMs(Math.max(minSleepMs, 10000)); + } + } + } else { + // Normal Single Run + try { + await evolve.run(); + } catch (error) { + console.error('Evolution failed:', error); + process.exit(1); + } + } + + // Post-run hint + console.log('\n' + '======================================================='); + console.log('Capability evolver finished. If you use this project, consider starring the upstream repository.'); + console.log('Upstream: https://github.com/autogame-17/capability-evolver'); + console.log('=======================================================\n'); + + } else if (command === 'solidify') { + const dryRun = args.includes('--dry-run'); + const noRollback = args.includes('--no-rollback'); + const intentFlag = args.find(a => typeof a === 'string' && a.startsWith('--intent=')); + const summaryFlag = args.find(a => typeof a === 'string' && a.startsWith('--summary=')); + const intent = intentFlag ? intentFlag.slice('--intent='.length) : null; + const summary = summaryFlag ? summaryFlag.slice('--summary='.length) : null; + + try { + const res = solidify({ + intent: intent || undefined, + summary: summary || undefined, + dryRun, + rollbackOnFailure: !noRollback, + }); + const st = res && res.ok ? 'SUCCESS' : 'FAILED'; + console.log(`[SOLIDIFY] ${st}`); + if (res && res.gene) console.log(JSON.stringify(res.gene, null, 2)); + if (res && res.event) console.log(JSON.stringify(res.event, null, 2)); + if (res && res.capsule) console.log(JSON.stringify(res.capsule, null, 2)); + + if (res && res.ok && !dryRun) { + try { + const { shouldDistill, prepareDistillation } = require('./src/gep/skillDistiller'); + if (shouldDistill()) { + const dr = prepareDistillation(); + if (dr && dr.ok && dr.promptPath) { + console.log('\n[DISTILL_REQUEST]'); + console.log('Distillation prompt ready. Read the prompt file, process it with your LLM,'); + console.log('save the LLM response to a file, then run:'); + console.log(' node index.js distill --response-file='); + console.log('Prompt file: ' + dr.promptPath); + console.log('[/DISTILL_REQUEST]'); + } + } + } catch (e) { + console.warn('[Distiller] Init failed (non-fatal): ' + (e.message || e)); + } + } + + process.exit(res && res.ok ? 0 : 2); + } catch (error) { + console.error('[SOLIDIFY] Error:', error); + process.exit(2); + } + } else if (command === 'distill') { + const responseFileFlag = args.find(a => typeof a === 'string' && a.startsWith('--response-file=')); + if (!responseFileFlag) { + console.error('Usage: node index.js distill --response-file='); + process.exit(1); + } + const responseFilePath = responseFileFlag.slice('--response-file='.length); + try { + const responseText = fs.readFileSync(responseFilePath, 'utf8'); + const { completeDistillation } = require('./src/gep/skillDistiller'); + const result = completeDistillation(responseText); + if (result && result.ok) { + console.log('[Distiller] Gene produced: ' + result.gene.id); + console.log(JSON.stringify(result.gene, null, 2)); + } else { + console.warn('[Distiller] Distillation did not produce a gene: ' + (result && result.reason || 'unknown')); + } + process.exit(result && result.ok ? 0 : 2); + } catch (error) { + console.error('[DISTILL] Error:', error); + process.exit(2); + } + + } else { + console.log(`Usage: node index.js [run|/evolve|solidify|distill] [--loop] + - solidify flags: + - --dry-run + - --no-rollback + - --intent=repair|optimize|innovate + - --summary=... + - distill flags: + - --response-file= (LLM response file for skill distillation)`); + } +} + +if (require.main === module) { + main(); +} diff --git a/skills/capability-evolver/package.json b/skills/capability-evolver/package.json new file mode 100644 index 0000000..95030e3 --- /dev/null +++ b/skills/capability-evolver/package.json @@ -0,0 +1,28 @@ +{ + "name": "evolver", + "version": "1.20.4", + "description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.", + "main": "index.js", + "keywords": [ + "openclaw", + "ai", + "evolution", + "meta-learning", + "self-repair", + "automation", + "agent" + ], + "author": "OpenClaw", + "license": "MIT", + "scripts": { + "start": "node index.js", + "run": "node index.js run", + "solidify": "node index.js solidify", + "a2a:export": "node scripts/a2a_export.js", + "a2a:ingest": "node scripts/a2a_ingest.js", + "a2a:promote": "node scripts/a2a_promote.js" + }, + "dependencies": { + "dotenv": "^16.4.7" + } +} diff --git a/skills/capability-evolver/scripts/a2a_export.js b/skills/capability-evolver/scripts/a2a_export.js new file mode 100644 index 0000000..a89dead --- /dev/null +++ b/skills/capability-evolver/scripts/a2a_export.js @@ -0,0 +1,63 @@ +const { loadGenes, loadCapsules, readAllEvents } = require('../src/gep/assetStore'); +const { exportEligibleCapsules, exportEligibleGenes, isAllowedA2AAsset } = require('../src/gep/a2a'); +const { buildPublish, buildHello, getTransport } = require('../src/gep/a2aProtocol'); +const { computeAssetId, SCHEMA_VERSION } = require('../src/gep/contentHash'); + +function main() { + var args = process.argv.slice(2); + var asJson = args.includes('--json'); + var asProtocol = args.includes('--protocol'); + var withHello = args.includes('--hello'); + var persist = args.includes('--persist'); + var includeEvents = args.includes('--include-events'); + + var capsules = loadCapsules(); + var genes = loadGenes(); + var events = readAllEvents(); + + // Build eligible list: Capsules (filtered) + Genes (filtered) + Events (opt-in) + var eligibleCapsules = exportEligibleCapsules({ capsules: capsules, events: events }); + var eligibleGenes = exportEligibleGenes({ genes: genes }); + var eligible = eligibleCapsules.concat(eligibleGenes); + + if (includeEvents) { + var eligibleEvents = (Array.isArray(events) ? events : []).filter(function (e) { + return isAllowedA2AAsset(e) && e.type === 'EvolutionEvent'; + }); + for (var ei = 0; ei < eligibleEvents.length; ei++) { + var ev = eligibleEvents[ei]; + if (!ev.schema_version) ev.schema_version = SCHEMA_VERSION; + if (!ev.asset_id) { try { ev.asset_id = computeAssetId(ev); } catch (e) {} } + } + eligible = eligible.concat(eligibleEvents); + } + + if (withHello || asProtocol) { + var hello = buildHello({ geneCount: genes.length, capsuleCount: capsules.length }); + process.stdout.write(JSON.stringify(hello) + '\n'); + if (persist) { try { getTransport().send(hello); } catch (e) {} } + } + + if (asProtocol) { + for (var i = 0; i < eligible.length; i++) { + var msg = buildPublish({ asset: eligible[i] }); + process.stdout.write(JSON.stringify(msg) + '\n'); + if (persist) { try { getTransport().send(msg); } catch (e) {} } + } + return; + } + + if (asJson) { + process.stdout.write(JSON.stringify(eligible, null, 2) + '\n'); + return; + } + + for (var j = 0; j < eligible.length; j++) { + process.stdout.write(JSON.stringify(eligible[j]) + '\n'); + } +} + +try { main(); } catch (e) { + process.stderr.write((e && e.message ? e.message : String(e)) + '\n'); + process.exit(1); +} diff --git a/skills/capability-evolver/scripts/a2a_ingest.js b/skills/capability-evolver/scripts/a2a_ingest.js new file mode 100644 index 0000000..c74409c --- /dev/null +++ b/skills/capability-evolver/scripts/a2a_ingest.js @@ -0,0 +1,79 @@ +var fs = require('fs'); +var assetStore = require('../src/gep/assetStore'); +var a2a = require('../src/gep/a2a'); +var memGraph = require('../src/gep/memoryGraphAdapter'); +var contentHash = require('../src/gep/contentHash'); +var a2aProto = require('../src/gep/a2aProtocol'); + +function readStdin() { + try { return fs.readFileSync(0, 'utf8'); } catch (e) { return ''; } +} + +function parseSignalsFromEnv() { + var raw = process.env.A2A_SIGNALS || ''; + if (!raw) return []; + try { + var maybe = JSON.parse(raw); + if (Array.isArray(maybe)) return maybe.map(String).filter(Boolean); + } catch (e) {} + return String(raw).split(',').map(function (s) { return s.trim(); }).filter(Boolean); +} + +function main() { + var args = process.argv.slice(2); + var inputPath = ''; + for (var i = 0; i < args.length; i++) { + if (args[i] && !args[i].startsWith('--')) { inputPath = args[i]; break; } + } + var source = process.env.A2A_SOURCE || 'external'; + var factor = Number.isFinite(Number(process.env.A2A_EXTERNAL_CONFIDENCE_FACTOR)) + ? Number(process.env.A2A_EXTERNAL_CONFIDENCE_FACTOR) : 0.6; + + var text = inputPath ? a2a.readTextIfExists(inputPath) : readStdin(); + var parsed = a2a.parseA2AInput(text); + var signals = parseSignalsFromEnv(); + + var accepted = 0; + var rejected = 0; + var emitDecisions = process.env.A2A_EMIT_DECISIONS === 'true'; + + for (var j = 0; j < parsed.length; j++) { + var obj = parsed[j]; + if (!a2a.isAllowedA2AAsset(obj)) continue; + + if (obj.asset_id && typeof obj.asset_id === 'string') { + if (!contentHash.verifyAssetId(obj)) { + rejected += 1; + if (emitDecisions) { + try { + var dm = a2aProto.buildDecision({ assetId: obj.asset_id, localId: obj.id, decision: 'reject', reason: 'asset_id integrity check failed' }); + a2aProto.getTransport().send(dm); + } catch (e) {} + } + continue; + } + } + + var staged = a2a.lowerConfidence(obj, { source: source, factor: factor }); + if (!staged) continue; + + assetStore.appendExternalCandidateJsonl(staged); + try { memGraph.recordExternalCandidate({ asset: staged, source: source, signals: signals }); } catch (e) {} + + if (emitDecisions) { + try { + var dm2 = a2aProto.buildDecision({ assetId: staged.asset_id, localId: staged.id, decision: 'quarantine', reason: 'staged as external candidate' }); + a2aProto.getTransport().send(dm2); + } catch (e) {} + } + + accepted += 1; + } + + process.stdout.write('accepted=' + accepted + ' rejected=' + rejected + '\n'); +} + +try { main(); } catch (e) { + process.stderr.write((e && e.message ? e.message : String(e)) + '\n'); + process.exit(1); +} diff --git a/skills/capability-evolver/scripts/a2a_promote.js b/skills/capability-evolver/scripts/a2a_promote.js new file mode 100644 index 0000000..d1d376d --- /dev/null +++ b/skills/capability-evolver/scripts/a2a_promote.js @@ -0,0 +1,118 @@ +var assetStore = require('../src/gep/assetStore'); +var solidifyMod = require('../src/gep/solidify'); +var contentHash = require('../src/gep/contentHash'); +var a2aProto = require('../src/gep/a2aProtocol'); + +function parseArgs(argv) { + var out = { flags: new Set(), kv: new Map(), positionals: [] }; + for (var i = 0; i < argv.length; i++) { + var a = argv[i]; + if (!a) continue; + if (a.startsWith('--')) { + var eq = a.indexOf('='); + if (eq > -1) { out.kv.set(a.slice(2, eq), a.slice(eq + 1)); } + else { + var key = a.slice(2); + var next = argv[i + 1]; + if (next && !String(next).startsWith('--')) { out.kv.set(key, next); i++; } + else { out.flags.add(key); } + } + } else { out.positionals.push(a); } + } + return out; +} + +function main() { + var args = parseArgs(process.argv.slice(2)); + var id = String(args.kv.get('id') || '').trim(); + var typeRaw = String(args.kv.get('type') || '').trim().toLowerCase(); + var validated = args.flags.has('validated') || String(args.kv.get('validated') || '') === 'true'; + var limit = Number.isFinite(Number(args.kv.get('limit'))) ? Number(args.kv.get('limit')) : 500; + + if (!id || !typeRaw) throw new Error('Usage: node scripts/a2a_promote.js --type capsule|gene|event --id --validated'); + if (!validated) throw new Error('Refusing to promote without --validated (local verification must be done first).'); + + var type = typeRaw === 'capsule' ? 'Capsule' : typeRaw === 'gene' ? 'Gene' : typeRaw === 'event' ? 'EvolutionEvent' : ''; + if (!type) throw new Error('Invalid --type. Use capsule, gene, or event.'); + + var external = assetStore.readRecentExternalCandidates(limit); + var candidate = null; + for (var i = 0; i < external.length; i++) { + if (external[i] && external[i].type === type && String(external[i].id) === id) { candidate = external[i]; break; } + } + if (!candidate) throw new Error('Candidate not found in external zone: type=' + type + ' id=' + id); + + if (type === 'Gene') { + var validation = Array.isArray(candidate.validation) ? candidate.validation : []; + for (var j = 0; j < validation.length; j++) { + var c = String(validation[j] || '').trim(); + if (!c) continue; + if (!solidifyMod.isValidationCommandAllowed(c)) { + throw new Error('Refusing to promote Gene ' + id + ': validation command rejected by safety check: "' + c + '". Only node/npm/npx commands without shell operators are allowed.'); + } + } + } + + var promoted = JSON.parse(JSON.stringify(candidate)); + if (!promoted.a2a || typeof promoted.a2a !== 'object') promoted.a2a = {}; + promoted.a2a.status = 'promoted'; + promoted.a2a.promoted_at = new Date().toISOString(); + if (!promoted.schema_version) promoted.schema_version = contentHash.SCHEMA_VERSION; + promoted.asset_id = contentHash.computeAssetId(promoted); + + var emitDecisions = process.env.A2A_EMIT_DECISIONS === 'true'; + + if (type === 'EvolutionEvent') { + assetStore.appendEventJsonl(promoted); + if (emitDecisions) { + try { + var dmEv = a2aProto.buildDecision({ assetId: promoted.asset_id, localId: id, decision: 'accept', reason: 'event promoted for provenance tracking' }); + a2aProto.getTransport().send(dmEv); + } catch (e) {} + } + process.stdout.write('promoted_event=' + id + '\n'); + return; + } + + if (type === 'Capsule') { + assetStore.appendCapsule(promoted); + if (emitDecisions) { + try { + var dm = a2aProto.buildDecision({ assetId: promoted.asset_id, localId: id, decision: 'accept', reason: 'capsule promoted after validation' }); + a2aProto.getTransport().send(dm); + } catch (e) {} + } + process.stdout.write('promoted_capsule=' + id + '\n'); + return; + } + + var localGenes = assetStore.loadGenes(); + var exists = false; + for (var k = 0; k < localGenes.length; k++) { + if (localGenes[k] && localGenes[k].type === 'Gene' && String(localGenes[k].id) === id) { exists = true; break; } + } + if (exists) { + if (emitDecisions) { + try { + var dm2 = a2aProto.buildDecision({ assetId: promoted.asset_id, localId: id, decision: 'reject', reason: 'local gene with same ID already exists' }); + a2aProto.getTransport().send(dm2); + } catch (e) {} + } + process.stdout.write('conflict_keep_local_gene=' + id + '\n'); + return; + } + + assetStore.upsertGene(promoted); + if (emitDecisions) { + try { + var dm3 = a2aProto.buildDecision({ assetId: promoted.asset_id, localId: id, decision: 'accept', reason: 'gene promoted after safety audit' }); + a2aProto.getTransport().send(dm3); + } catch (e) {} + } + process.stdout.write('promoted_gene=' + id + '\n'); +} + +try { main(); } catch (e) { + process.stderr.write((e && e.message ? e.message : String(e)) + '\n'); + process.exit(1); +} diff --git a/skills/capability-evolver/scripts/analyze_by_skill.js b/skills/capability-evolver/scripts/analyze_by_skill.js new file mode 100644 index 0000000..14ae3c4 --- /dev/null +++ b/skills/capability-evolver/scripts/analyze_by_skill.js @@ -0,0 +1,121 @@ +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const LOG_FILE = path.join(REPO_ROOT, 'evolution_history_full.md'); +const OUT_FILE = path.join(REPO_ROOT, 'evolution_detailed_report.md'); + +function analyzeEvolution() { + if (!fs.existsSync(LOG_FILE)) { + console.error("Source file missing."); + return; + } + + const content = fs.readFileSync(LOG_FILE, 'utf8'); + // Split by divider + const entries = content.split('---').map(e => e.trim()).filter(e => e.length > 0); + + const skillUpdates = {}; // Map> + const generalUpdates = []; // Array + + // Regex to detect skills/paths + // e.g. `skills/feishu-card/send.js` or **Target**: `skills/git-sync` + const skillRegex = /skills\/([a-zA-Z0-9\-_]+)/; + const actionRegex = /Action:\s*([\s\S]*?)(?=\n\n|\n[A-Z]|$)/i; // Capture Action text + const statusRegex = /Status:\s*\[?([A-Z\s_]+)\]?/i; + + entries.forEach(entry => { + // Extract basic info + const statusMatch = entry.match(statusRegex); + const status = statusMatch ? statusMatch[1].trim().toUpperCase() : 'UNKNOWN'; + + // Skip routine checks if we want a *detailed evolution* report (focus on changes) + // But user asked for "what happened", so routine scans might be boring unless they found something. + // Let's filter out "STABILITY" or "RUNNING" unless there is a clear "Mutated" or "Fixed" keyword. + const isInteresting = + entry.includes('Fixed') || + entry.includes('Hardened') || + entry.includes('Optimized') || + entry.includes('Patched') || + entry.includes('Created') || + entry.includes('Added') || + status === 'SUCCESS' || + status === 'COMPLETED'; + + if (!isInteresting) return; + + // Find associated skill + const skillMatch = entry.match(skillRegex); + let skillName = 'General / System'; + if (skillMatch) { + skillName = skillMatch[1]; + } else { + // Try heuristics + if (entry.toLowerCase().includes('feishu card')) skillName = 'feishu-card'; + else if (entry.toLowerCase().includes('git sync')) skillName = 'git-sync'; + else if (entry.toLowerCase().includes('logger')) skillName = 'interaction-logger'; + else if (entry.toLowerCase().includes('evolve')) skillName = 'capability-evolver'; + } + + // Extract description + let description = ""; + const actionMatch = entry.match(actionRegex); + if (actionMatch) { + description = actionMatch[1].trim(); + } else { + // Fallback: take lines that look like bullet points or text after header + const lines = entry.split('\n'); + description = lines.filter(l => l.match(/^[•\-\*]|\w/)).slice(1).join('\n').trim(); + } + + // Clean up description (remove duplicate "Action:" prefix if captured) + description = description.replace(/^Action:\s*/i, ''); + + if (!skillUpdates[skillName]) skillUpdates[skillName] = []; + + // Dedup descriptions slightly (simple check) + const isDuplicate = skillUpdates[skillName].some(u => u.desc.includes(description.substring(0, 20))); + if (!isDuplicate) { + // Extract Date if possible + const dateMatch = entry.match(/\((\d{4}\/\d{1,2}\/\d{1,2}.*?)\)/); + const date = dateMatch ? dateMatch[1] : 'Unknown'; + + skillUpdates[skillName].push({ + date, + status, + desc: description + }); + } + }); + + // Generate Markdown + let md = "# Detailed Evolution Report (By Skill)\n\n> Comprehensive breakdown of system changes.\n\n"; + + // Sort skills alphabetically + const sortedSkills = Object.keys(skillUpdates).sort(); + + sortedSkills.forEach(skill => { + md += `## ${skill}\n`; + const updates = skillUpdates[skill]; + + updates.forEach(u => { + // Icon based on content + let icon = '*'; + const lowerDesc = u.desc.toLowerCase(); + if (lowerDesc.includes('optimiz')) icon = '[optimize]'; + if (lowerDesc.includes('secur') || lowerDesc.includes('harden') || lowerDesc.includes('permission')) icon = '[security]'; + if (lowerDesc.includes('fix') || lowerDesc.includes('patch')) icon = '[repair]'; + if (lowerDesc.includes('creat') || lowerDesc.includes('add')) icon = '[add]'; + + md += `### ${icon} ${u.date}\n`; + md += `${u.desc}\n\n`; + }); + md += `---\n`; + }); + + fs.writeFileSync(OUT_FILE, md); + console.log(`Generated report for ${sortedSkills.length} skills.`); +} + +analyzeEvolution(); + diff --git a/skills/capability-evolver/scripts/build_public.js b/skills/capability-evolver/scripts/build_public.js new file mode 100644 index 0000000..e237487 --- /dev/null +++ b/skills/capability-evolver/scripts/build_public.js @@ -0,0 +1,354 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function rmDir(dir) { + if (!fs.existsSync(dir)) return; + fs.rmSync(dir, { recursive: true, force: true }); +} + +function normalizePosix(p) { + return p.split(path.sep).join('/'); +} + +function isUnder(child, parent) { + const rel = path.relative(parent, child); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function listFilesRec(dir) { + const out = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) out.push(...listFilesRec(p)); + else if (ent.isFile()) out.push(p); + } + return out; +} + +function globToRegex(glob) { + // Supports "*" within a single segment and "**" for any depth. + const norm = normalizePosix(glob); + const parts = norm.split('/').filter(p => p.length > 0); + const out = []; + + for (const part of parts) { + if (part === '**') { + // any number of path segments + out.push('(?:.*)'); + continue; + } + // Escape regex special chars, then expand "*" wildcards within segment. + const esc = part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'); + out.push(esc); + } + + const re = out.join('\\/'); + return new RegExp(`^${re}$`); +} + +function matchesAnyGlobs(relPath, globs) { + const p = normalizePosix(relPath); + for (const g of globs || []) { + const re = globToRegex(g); + if (re.test(p)) return true; + } + return false; +} + +function copyFile(srcAbs, destAbs) { + ensureDir(path.dirname(destAbs)); + fs.copyFileSync(srcAbs, destAbs); +} + +function copyEntry(spec, outDirAbs) { + const copied = []; + + // Directory glob + if (spec.includes('*')) { + const all = listFilesRec(REPO_ROOT); + const includeRe = globToRegex(spec); + for (const abs of all) { + const rel = normalizePosix(path.relative(REPO_ROOT, abs)); + if (!includeRe.test(rel)) continue; + const destAbs = path.join(outDirAbs, rel); + copyFile(abs, destAbs); + copied.push(rel); + } + return copied; + } + + const srcAbs = path.join(REPO_ROOT, spec); + if (!fs.existsSync(srcAbs)) return []; + + const st = fs.statSync(srcAbs); + if (st.isFile()) { + const rel = normalizePosix(spec); + copyFile(srcAbs, path.join(outDirAbs, rel)); + copied.push(rel); + return copied; + } + if (st.isDirectory()) { + const files = listFilesRec(srcAbs); + for (const abs of files) { + const rel = normalizePosix(path.relative(REPO_ROOT, abs)); + copyFile(abs, path.join(outDirAbs, rel)); + copied.push(rel); + } + } + return copied; +} + +function applyRewrite(outDirAbs, rewrite) { + const rules = rewrite || {}; + for (const [relFile, cfg] of Object.entries(rules)) { + const target = path.join(outDirAbs, relFile); + if (!fs.existsSync(target)) continue; + let content = fs.readFileSync(target, 'utf8'); + const reps = (cfg && cfg.replace) || []; + for (const r of reps) { + const from = String(r.from || ''); + const to = String(r.to || ''); + if (!from) continue; + content = content.split(from).join(to); + } + fs.writeFileSync(target, content, 'utf8'); + } +} + +function rewritePackageJson(outDirAbs) { + const p = path.join(outDirAbs, 'package.json'); + if (!fs.existsSync(p)) return; + try { + const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); + pkg.scripts = { + start: 'node index.js', + run: 'node index.js run', + solidify: 'node index.js solidify', + 'a2a:export': 'node scripts/a2a_export.js', + 'a2a:ingest': 'node scripts/a2a_ingest.js', + 'a2a:promote': 'node scripts/a2a_promote.js', + }; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + } catch (e) { + // ignore + } +} + +function parseSemver(v) { + const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; +} + +function formatSemver(x) { + return `${x.major}.${x.minor}.${x.patch}`; +} + +function bumpSemver(base, bump) { + const v = parseSemver(base); + if (!v) return null; + if (bump === 'major') return `${v.major + 1}.0.0`; + if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`; + if (bump === 'patch') return `${v.major}.${v.minor}.${v.patch + 1}`; + return formatSemver(v); +} + +function git(cmd) { + return execSync(cmd, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); +} + +function getBaseReleaseCommit() { + // Prefer last "prepare vX.Y.Z" commit; fallback to HEAD~50 range later. + try { + const hash = git('git log -n 1 --pretty=%H --grep="chore(release): prepare v"'); + return hash || null; + } catch (e) { + return null; + } +} + +function getCommitSubjectsSince(baseCommit) { + try { + if (!baseCommit) { + const out = git('git log -n 30 --pretty=%s'); + return out ? out.split('\n').filter(Boolean) : []; + } + const out = git(`git log ${baseCommit}..HEAD --pretty=%s`); + return out ? out.split('\n').filter(Boolean) : []; + } catch (e) { + return []; + } +} + +function inferBumpFromSubjects(subjects) { + const subs = (subjects || []).map(s => String(s)); + const hasBreaking = subs.some(s => /\bBREAKING CHANGE\b/i.test(s) || /^[a-z]+(\(.+\))?!:/.test(s)); + if (hasBreaking) return { bump: 'major', reason: 'breaking change marker in commit subject' }; + + const hasFeat = subs.some(s => /^feat(\(.+\))?:/i.test(s)); + if (hasFeat) return { bump: 'minor', reason: 'feature commit detected (feat:)' }; + + const hasFix = subs.some(s => /^(fix|perf)(\(.+\))?:/i.test(s)); + if (hasFix) return { bump: 'patch', reason: 'fix/perf commit detected' }; + + if (subs.length === 0) return { bump: 'none', reason: 'no commits since base release commit' }; + return { bump: 'patch', reason: 'default to patch for non-breaking changes' }; +} + +function suggestVersion() { + const pkgPath = path.join(REPO_ROOT, 'package.json'); + let baseVersion = null; + try { + baseVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; + } catch (e) {} + + const baseCommit = getBaseReleaseCommit(); + const subjects = getCommitSubjectsSince(baseCommit); + const decision = inferBumpFromSubjects(subjects); + + let suggested = null; + if (decision.bump === 'none') suggested = baseVersion; + else suggested = bumpSemver(baseVersion, decision.bump); + + return { baseVersion, baseCommit, subjects, decision, suggestedVersion: suggested }; +} + +function writePrivateSemverNote(note) { + const privateDir = path.join(REPO_ROOT, 'memory'); + ensureDir(privateDir); + fs.writeFileSync(path.join(privateDir, 'semver_suggestion.json'), JSON.stringify(note, null, 2) + '\n', 'utf8'); +} + +function writePrivateSemverPrompt(note) { + const privateDir = path.join(REPO_ROOT, 'memory'); + ensureDir(privateDir); + const subjects = Array.isArray(note.subjects) ? note.subjects : []; + const semverRule = [ + 'MAJOR.MINOR.PATCH', + '- MAJOR: incompatible changes', + '- MINOR: backward-compatible features', + '- PATCH: backward-compatible bug fixes', + ].join('\n'); + + const prompt = [ + 'You are a release versioning assistant.', + 'Decide the next version bump using SemVer rules below.', + '', + semverRule, + '', + `Base version: ${note.baseVersion || '(unknown)'}`, + `Base commit: ${note.baseCommit || '(unknown)'}`, + '', + 'Recent commit subjects (newest first):', + ...subjects.map(s => `- ${s}`), + '', + 'Output JSON only:', + '{ "bump": "major|minor|patch|none", "suggestedVersion": "x.y.z", "reason": ["..."] }', + ].join('\n'); + + fs.writeFileSync(path.join(privateDir, 'semver_prompt.md'), prompt + '\n', 'utf8'); +} + +function writeDistVersion(outDirAbs, version) { + if (!version) return; + const p = path.join(outDirAbs, 'package.json'); + if (!fs.existsSync(p)) return; + try { + const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); + pkg.version = version; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + } catch (e) {} +} + +function pruneExcluded(outDirAbs, excludeGlobs) { + const all = listFilesRec(outDirAbs); + for (const abs of all) { + const rel = normalizePosix(path.relative(outDirAbs, abs)); + if (matchesAnyGlobs(rel, excludeGlobs)) { + fs.rmSync(abs, { force: true }); + } + } +} + +function validateNoPrivatePaths(outDirAbs) { + // Basic safeguard: forbid docs/ and memory/ in output. + const forbiddenPrefixes = ['docs/', 'memory/']; + const all = listFilesRec(outDirAbs); + for (const abs of all) { + const rel = normalizePosix(path.relative(outDirAbs, abs)); + for (const pref of forbiddenPrefixes) { + if (rel.startsWith(pref)) { + throw new Error(`Build validation failed: forbidden path in output: ${rel}`); + } + } + } +} + +function main() { + const manifestPath = path.join(REPO_ROOT, 'public.manifest.json'); + const manifest = readJson(manifestPath); + const outDir = String(manifest.outDir || 'dist-public'); + const outDirAbs = path.join(REPO_ROOT, outDir); + + // SemVer suggestion (private). This does not modify the source repo version. + const semver = suggestVersion(); + writePrivateSemverNote(semver); + writePrivateSemverPrompt(semver); + + rmDir(outDirAbs); + ensureDir(outDirAbs); + + const include = manifest.include || []; + const exclude = manifest.exclude || []; + + const copied = []; + for (const spec of include) { + copied.push(...copyEntry(spec, outDirAbs)); + } + + pruneExcluded(outDirAbs, exclude); + applyRewrite(outDirAbs, manifest.rewrite); + rewritePackageJson(outDirAbs); + + // Prefer explicit version; otherwise use suggested version. + const releaseVersion = process.env.RELEASE_VERSION || semver.suggestedVersion; + if (releaseVersion) writeDistVersion(outDirAbs, releaseVersion); + + validateNoPrivatePaths(outDirAbs); + + // Write build manifest for private verification (do not include in dist-public/). + const buildInfo = { + built_at: new Date().toISOString(), + outDir, + files: copied.sort(), + }; + const privateDir = path.join(REPO_ROOT, 'memory'); + ensureDir(privateDir); + fs.writeFileSync(path.join(privateDir, 'public_build_info.json'), JSON.stringify(buildInfo, null, 2) + '\n', 'utf8'); + + process.stdout.write(`Built public output at ${outDir}\n`); + if (semver && semver.suggestedVersion) { + process.stdout.write(`Suggested version: ${semver.suggestedVersion}\n`); + process.stdout.write(`SemVer decision: ${semver.decision ? semver.decision.bump : 'unknown'}\n`); + } +} + +try { + main(); +} catch (e) { + process.stderr.write(`${e.message}\n`); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/extract_log.js b/skills/capability-evolver/scripts/extract_log.js new file mode 100644 index 0000000..bff7190 --- /dev/null +++ b/skills/capability-evolver/scripts/extract_log.js @@ -0,0 +1,85 @@ +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const LOG_FILE = path.join(REPO_ROOT, 'memory', 'mad_dog_evolution.log'); +const OUT_FILE = path.join(REPO_ROOT, 'evolution_history.md'); + +function parseLog() { + if (!fs.existsSync(LOG_FILE)) { + console.log("Log file not found."); + return; + } + + const content = fs.readFileSync(LOG_FILE, 'utf8'); + const lines = content.split('\n'); + + const reports = []; + let currentTimestamp = null; + + // Regex for Feishu command + // node skills/feishu-card/send.js --title "..." --color ... --text "..." + const cmdRegex = /node skills\/feishu-card\/send\.js --title "(.*?)" --color \w+ --text "(.*?)"/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // 1. Capture Timestamp + if (line.includes('Cycle Start:')) { + // Format: Cycle Start: Sun Feb 1 19:17:44 UTC 2026 + const dateStr = line.split('Cycle Start: ')[1].trim(); + try { + currentTimestamp = new Date(dateStr); + } catch (e) { + currentTimestamp = null; + } + } + + const match = line.match(cmdRegex); + if (match) { + const title = match[1]; + let text = match[2]; + + // Clean up text (unescape newlines) + text = text.replace(/\\n/g, '\n').replace(/\\"/g, '"'); + + if (currentTimestamp) { + reports.push({ + ts: currentTimestamp, + title: title, + text: text, + id: title // Cycle ID is in title + }); + } + } + } + + // Deduplicate by ID (keep latest timestamp?) + const uniqueReports = {}; + reports.forEach(r => { + uniqueReports[r.id] = r; + }); + + const sortedReports = Object.values(uniqueReports).sort((a, b) => a.ts - b.ts); + + let md = "# Evolution History (Extracted)\n\n"; + sortedReports.forEach(r => { + // Convert to CST (UTC+8) + const cstDate = r.ts.toLocaleString("zh-CN", { + timeZone: "Asia/Shanghai", + hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + + md += `### ${r.title} (${cstDate})\n`; + md += `${r.text}\n\n`; + md += `---\n\n`; + }); + + fs.writeFileSync(OUT_FILE, md); + console.log(`Extracted ${sortedReports.length} reports to ${OUT_FILE}`); +} + +parseLog(); + diff --git a/skills/capability-evolver/scripts/generate_history.js b/skills/capability-evolver/scripts/generate_history.js new file mode 100644 index 0000000..f36d195 --- /dev/null +++ b/skills/capability-evolver/scripts/generate_history.js @@ -0,0 +1,75 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// Separator for git log parsing (something unlikely to be in commit messages) +const SEP = '|||'; +const REPO_ROOT = path.resolve(__dirname, '..'); + +try { + // Git command: + // --reverse: Oldest to Newest (Time Sequence) + // --grep: Filter by keyword + // --format: Hash, Date (ISO), Author, Subject, Body + const cmd = `git log --reverse --grep="Evolution" --format="%H${SEP}%ai${SEP}%an${SEP}%s${SEP}%b"`; + + console.log('Executing git log...'); + const output = execSync(cmd, { + encoding: 'utf8', + cwd: REPO_ROOT, + maxBuffer: 1024 * 1024 * 10 // 10MB buffer just in case + }); + + const entries = output.split('\n').filter(line => line.trim().length > 0); + + let markdown = '# Evolution History (Time Sequence)\n\n'; + markdown += '> Filter: "Evolution"\n'; + markdown += '> Timezone: CST (UTC+8)\n\n'; + + let count = 0; + + entries.forEach(entry => { + const parts = entry.split(SEP); + if (parts.length < 4) return; + + const hash = parts[0]; + const dateStr = parts[1]; + const author = parts[2]; + const subject = parts[3]; + const body = parts[4] || ''; + + // Parse Date and Convert to UTC+8 + const date = new Date(dateStr); + // Add 8 hours (28800000 ms) to UTC timestamp to shift it + // Then formatting it as ISO will look like UTC but represent CST values + const cstDate = new Date(date.getTime() + 8 * 60 * 60 * 1000); + + // Format: YYYY-MM-DD HH:mm:ss + const timeStr = cstDate.toISOString().replace('T', ' ').substring(0, 19); + + markdown += `## ${timeStr}\n`; + markdown += `- Commit: \`${hash.substring(0, 7)}\`\n`; + markdown += `- Subject: ${subject}\n`; + + if (body.trim()) { + // Indent body for better readability + const formattedBody = body.trim().split('\n').map(l => `> ${l}`).join('\n'); + markdown += `- Details:\n${formattedBody}\n`; + } + markdown += '\n'; + count++; + }); + + const outDir = path.join(REPO_ROOT, 'memory'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + const outPath = path.join(outDir, 'evolution_history.md'); + fs.writeFileSync(outPath, markdown); + + console.log(`Successfully generated report with ${count} entries.`); + console.log(`Saved to: ${outPath}`); + +} catch (e) { + console.error('Error generating history:', e.message); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/gep_append_event.js b/skills/capability-evolver/scripts/gep_append_event.js new file mode 100644 index 0000000..e863c41 --- /dev/null +++ b/skills/capability-evolver/scripts/gep_append_event.js @@ -0,0 +1,96 @@ +const fs = require('fs'); +const { appendEventJsonl } = require('../src/gep/assetStore'); + +function readStdin() { + try { + return fs.readFileSync(0, 'utf8'); + } catch { + return ''; + } +} + +function readTextIfExists(p) { + try { + if (!p) return ''; + if (!fs.existsSync(p)) return ''; + return fs.readFileSync(p, 'utf8'); + } catch { + return ''; + } +} + +function parseInput(text) { + const raw = String(text || '').trim(); + if (!raw) return []; + + // Accept JSON array or single JSON. + try { + const maybe = JSON.parse(raw); + if (Array.isArray(maybe)) return maybe; + if (maybe && typeof maybe === 'object') return [maybe]; + } catch (e) {} + + // Fallback: JSONL. + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + const out = []; + for (const line of lines) { + try { + const obj = JSON.parse(line); + out.push(obj); + } catch (e) {} + } + return out; +} + +function isValidEvolutionEvent(ev) { + if (!ev || ev.type !== 'EvolutionEvent') return false; + if (!ev.id || typeof ev.id !== 'string') return false; + // parent may be null or string + if (!(ev.parent === null || typeof ev.parent === 'string')) return false; + if (!ev.intent || typeof ev.intent !== 'string') return false; + if (!Array.isArray(ev.signals)) return false; + if (!Array.isArray(ev.genes_used)) return false; + // GEP v1.4: mutation + personality are mandatory evolution dimensions + if (!ev.mutation_id || typeof ev.mutation_id !== 'string') return false; + if (!ev.personality_state || typeof ev.personality_state !== 'object') return false; + if (ev.personality_state.type !== 'PersonalityState') return false; + for (const k of ['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience']) { + const v = Number(ev.personality_state[k]); + if (!Number.isFinite(v) || v < 0 || v > 1) return false; + } + if (!ev.blast_radius || typeof ev.blast_radius !== 'object') return false; + if (!Number.isFinite(Number(ev.blast_radius.files))) return false; + if (!Number.isFinite(Number(ev.blast_radius.lines))) return false; + if (!ev.outcome || typeof ev.outcome !== 'object') return false; + if (!ev.outcome.status || typeof ev.outcome.status !== 'string') return false; + const score = Number(ev.outcome.score); + if (!Number.isFinite(score) || score < 0 || score > 1) return false; + + // capsule_id is optional, but if present must be string or null. + if (!('capsule_id' in ev)) return true; + return ev.capsule_id === null || typeof ev.capsule_id === 'string'; +} + +function main() { + const args = process.argv.slice(2); + const inputPath = args.find(a => a && !a.startsWith('--')) || ''; + const text = inputPath ? readTextIfExists(inputPath) : readStdin(); + const items = parseInput(text); + + let appended = 0; + for (const it of items) { + if (!isValidEvolutionEvent(it)) continue; + appendEventJsonl(it); + appended += 1; + } + + process.stdout.write(`appended=${appended}\n`); +} + +try { + main(); +} catch (e) { + process.stderr.write(`${e && e.message ? e.message : String(e)}\n`); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/gep_personality_report.js b/skills/capability-evolver/scripts/gep_personality_report.js new file mode 100644 index 0000000..080f2d7 --- /dev/null +++ b/skills/capability-evolver/scripts/gep_personality_report.js @@ -0,0 +1,234 @@ +const fs = require('fs'); +const path = require('path'); +const { getRepoRoot, getMemoryDir, getGepAssetsDir } = require('../src/gep/paths'); +const { normalizePersonalityState, personalityKey, defaultPersonalityState } = require('../src/gep/personality'); + +function readJsonIfExists(p, fallback) { + try { + if (!fs.existsSync(p)) return fallback; + const raw = fs.readFileSync(p, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function readJsonlIfExists(p, limitLines = 5000) { + try { + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw + .split('\n') + .map(l => l.trim()) + .filter(Boolean); + const recent = lines.slice(Math.max(0, lines.length - limitLines)); + return recent + .map(l => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + return []; + } +} + +function clamp01(x) { + const n = Number(x); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +function pct(x) { + const n = Number(x); + if (!Number.isFinite(n)) return '0.0%'; + return `${(n * 100).toFixed(1)}%`; +} + +function pad(s, n) { + const str = String(s == null ? '' : s); + if (str.length >= n) return str.slice(0, n); + return str + ' '.repeat(n - str.length); +} + +function scoreFromCounts(success, fail, avgScore) { + const succ = Number(success) || 0; + const fl = Number(fail) || 0; + const total = succ + fl; + const p = (succ + 1) / (total + 2); // Laplace smoothing + const sampleWeight = Math.min(1, total / 8); + const q = avgScore == null ? 0.5 : clamp01(avgScore); + return p * 0.75 + q * 0.25 * sampleWeight; +} + +function aggregateFromEvents(events) { + const map = new Map(); + for (const ev of Array.isArray(events) ? events : []) { + if (!ev || ev.type !== 'EvolutionEvent') continue; + const ps = ev.personality_state && typeof ev.personality_state === 'object' ? ev.personality_state : null; + if (!ps) continue; + const key = personalityKey(normalizePersonalityState(ps)); + const cur = map.get(key) || { + key, + success: 0, + fail: 0, + n: 0, + avg_score: 0.5, + last_event_id: null, + last_at: null, + mutation: { repair: 0, optimize: 0, innovate: 0 }, + mutation_success: { repair: 0, optimize: 0, innovate: 0 }, + }; + const st = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown'; + if (st === 'success') cur.success += 1; + else if (st === 'failed') cur.fail += 1; + + const sc = ev.outcome && Number.isFinite(Number(ev.outcome.score)) ? clamp01(Number(ev.outcome.score)) : null; + if (sc != null) { + cur.n += 1; + cur.avg_score = cur.avg_score + (sc - cur.avg_score) / cur.n; + } + + const cat = ev.intent ? String(ev.intent) : null; + if (cat && cur.mutation[cat] != null) { + cur.mutation[cat] += 1; + if (st === 'success') cur.mutation_success[cat] += 1; + } + + cur.last_event_id = ev.id || cur.last_event_id; + const at = ev.meta && ev.meta.at ? String(ev.meta.at) : null; + cur.last_at = at || cur.last_at; + map.set(key, cur); + } + return Array.from(map.values()); +} + +function main() { + const repoRoot = getRepoRoot(); + const memoryDir = getMemoryDir(); + const assetsDir = getGepAssetsDir(); + + const personalityPath = path.join(memoryDir, 'personality_state.json'); + const model = readJsonIfExists(personalityPath, null); + const current = model && model.current ? normalizePersonalityState(model.current) : defaultPersonalityState(); + const currentKey = personalityKey(current); + + const eventsPath = path.join(assetsDir, 'events.jsonl'); + const events = readJsonlIfExists(eventsPath, 10000); + const evs = events.filter(e => e && e.type === 'EvolutionEvent'); + const agg = aggregateFromEvents(evs); + + // Prefer model.stats if present, but still show event-derived aggregation (ground truth). + const stats = model && model.stats && typeof model.stats === 'object' ? model.stats : {}; + const statRows = Object.entries(stats).map(([key, e]) => { + const entry = e && typeof e === 'object' ? e : {}; + const success = Number(entry.success) || 0; + const fail = Number(entry.fail) || 0; + const total = success + fail; + const avg = Number.isFinite(Number(entry.avg_score)) ? clamp01(Number(entry.avg_score)) : null; + const score = scoreFromCounts(success, fail, avg); + return { key, success, fail, total, avg_score: avg, score, updated_at: entry.updated_at || null, source: 'model' }; + }); + + const evRows = agg.map(e => { + const success = Number(e.success) || 0; + const fail = Number(e.fail) || 0; + const total = success + fail; + const avg = Number.isFinite(Number(e.avg_score)) ? clamp01(Number(e.avg_score)) : null; + const score = scoreFromCounts(success, fail, avg); + return { key: e.key, success, fail, total, avg_score: avg, score, updated_at: e.last_at || null, source: 'events', _ev: e }; + }); + + // Merge rows by key (events take precedence for total/success/fail; model provides updated_at if events missing). + const byKey = new Map(); + for (const r of [...statRows, ...evRows]) { + const prev = byKey.get(r.key); + if (!prev) { + byKey.set(r.key, r); + continue; + } + // Prefer events for counts and avg_score + if (r.source === 'events') byKey.set(r.key, { ...prev, ...r }); + else byKey.set(r.key, { ...r, ...prev }); + } + + const merged = Array.from(byKey.values()).sort((a, b) => b.score - a.score); + + process.stdout.write(`Repo: ${repoRoot}\n`); + process.stdout.write(`MemoryDir: ${memoryDir}\n`); + process.stdout.write(`AssetsDir: ${assetsDir}\n\n`); + + process.stdout.write(`[Current Personality]\n`); + process.stdout.write(`${currentKey}\n`); + process.stdout.write(`${JSON.stringify(current, null, 2)}\n\n`); + + process.stdout.write(`[Personality Stats] (ranked by score)\n`); + if (merged.length === 0) { + process.stdout.write('(no stats yet; run a few cycles and solidify)\n'); + return; + } + + const header = + pad('rank', 5) + + pad('total', 8) + + pad('succ', 8) + + pad('fail', 8) + + pad('succ_rate', 11) + + pad('avg', 7) + + pad('score', 8) + + 'key'; + process.stdout.write(header + '\n'); + process.stdout.write('-'.repeat(Math.min(140, header.length + 40)) + '\n'); + + const topN = Math.min(25, merged.length); + for (let i = 0; i < topN; i++) { + const r = merged[i]; + const succ = Number(r.success) || 0; + const fail = Number(r.fail) || 0; + const total = Number(r.total) || succ + fail; + const succRate = total > 0 ? succ / total : 0; + const avg = r.avg_score == null ? '-' : Number(r.avg_score).toFixed(2); + const line = + pad(String(i + 1), 5) + + pad(String(total), 8) + + pad(String(succ), 8) + + pad(String(fail), 8) + + pad(pct(succRate), 11) + + pad(String(avg), 7) + + pad(Number(r.score).toFixed(3), 8) + + String(r.key); + process.stdout.write(line + '\n'); + + if (r._ev) { + const ev = r._ev; + const ms = ev.mutation || {}; + const mSucc = ev.mutation_success || {}; + const parts = []; + for (const cat of ['repair', 'optimize', 'innovate']) { + const n = Number(ms[cat]) || 0; + if (n <= 0) continue; + const s = Number(mSucc[cat]) || 0; + parts.push(`${cat}:${s}/${n}`); + } + if (parts.length) process.stdout.write(` mutation_success: ${parts.join(' | ')}\n`); + } + } + + process.stdout.write('\n'); + process.stdout.write(`[Notes]\n`); + process.stdout.write(`- score is a smoothed composite of success_rate + avg_score (sample-weighted)\n`); + process.stdout.write(`- current_key appears in the ranking once enough data accumulates\n`); +} + +try { + main(); +} catch (e) { + process.stderr.write((e && e.message) || String(e)); + process.stderr.write('\n'); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/human_report.js b/skills/capability-evolver/scripts/human_report.js new file mode 100644 index 0000000..64c5e79 --- /dev/null +++ b/skills/capability-evolver/scripts/human_report.js @@ -0,0 +1,147 @@ +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const IN_FILE = path.join(REPO_ROOT, 'evolution_history_full.md'); +const OUT_FILE = path.join(REPO_ROOT, 'evolution_human_summary.md'); + +function generateHumanReport() { + if (!fs.existsSync(IN_FILE)) return console.error("No input file"); + + const content = fs.readFileSync(IN_FILE, 'utf8'); + const entries = content.split('---').map(e => e.trim()).filter(e => e.length > 0); + + const categories = { + 'Security & Stability': [], + 'Performance & Optimization': [], + 'Tooling & Features': [], + 'Documentation & Process': [] + }; + + const componentMap = {}; // Component -> Change List + + entries.forEach(entry => { + // Extract basic info + const lines = entry.split('\n'); + const header = lines[0]; // ### Title (Date) + const body = lines.slice(1).join('\n'); + + const dateMatch = header.match(/\((.*?)\)/); + const dateStr = dateMatch ? dateMatch[1] : ''; + const time = dateStr.split(' ')[1] || ''; // HH:mm:ss + + // Classify + let category = 'Tooling & Features'; + let component = 'System'; + let summary = ''; + + const lowerBody = body.toLowerCase(); + + // Detect Component + if (lowerBody.includes('feishu-card')) component = 'feishu-card'; + else if (lowerBody.includes('feishu-sticker')) component = 'feishu-sticker'; + else if (lowerBody.includes('git-sync')) component = 'git-sync'; + else if (lowerBody.includes('capability-evolver') || lowerBody.includes('evolve.js')) component = 'capability-evolver'; + else if (lowerBody.includes('interaction-logger')) component = 'interaction-logger'; + else if (lowerBody.includes('chat-to-image')) component = 'chat-to-image'; + else if (lowerBody.includes('safe_publish')) component = 'capability-evolver'; + + // Detect Category + if (lowerBody.includes('security') || lowerBody.includes('permission') || lowerBody.includes('auth') || lowerBody.includes('harden')) { + category = 'Security & Stability'; + } else if (lowerBody.includes('optimiz') || lowerBody.includes('performance') || lowerBody.includes('memory') || lowerBody.includes('fast')) { + category = 'Performance & Optimization'; + } else if (lowerBody.includes('doc') || lowerBody.includes('readme')) { + category = 'Documentation & Process'; + } + + // Extract Human Summary (First meaningful line that isn't Status/Action/Date) + const summaryLines = lines.filter(l => + !l.startsWith('###') && + !l.startsWith('Status:') && + !l.startsWith('Action:') && + l.trim().length > 10 + ); + + if (summaryLines.length > 0) { + // Clean up the line + summary = summaryLines[0] + .replace(/^-\s*/, '') // Remove bullets + .replace(/\*\*/g, '') // Remove bold + .replace(/`/, '') + .trim(); + + // Deduplicate + const key = `${component}:${summary.substring(0, 20)}`; + const exists = categories[category].some(i => i.key === key); + + if (!exists && !summary.includes("Stability Scan OK") && !summary.includes("Workspace Sync")) { + categories[category].push({ time, component, summary, key }); + + if (!componentMap[component]) componentMap[component] = []; + componentMap[component].push(summary); + } + } + }); + + // --- Generate Markdown --- + const today = new Date().toISOString().slice(0, 10); + let md = `# Evolution Summary: The Day in Review (${today})\n\n`; + md += `> Overview: Grouped summary of changes extracted from evolution history.\n\n`; + + // Section 1: By Theme (Evolution Direction) + md += `## 1. Evolution Direction\n`; + + for (const [cat, items] of Object.entries(categories)) { + if (items.length === 0) continue; + md += `### ${cat}\n`; + // Group by component within theme + const compGroup = {}; + items.forEach(i => { + if (!compGroup[i.component]) compGroup[i.component] = []; + compGroup[i.component].push(i.summary); + }); + + for (const [comp, sums] of Object.entries(compGroup)) { + // Unique summaries only + const uniqueSums = [...new Set(sums)]; + uniqueSums.forEach(s => { + md += `- **${comp}**: ${s}\n`; + }); + } + md += `\n`; + } + + // Section 2: By Timeline (High Level) + md += `## 2. Timeline of Critical Events\n`; + // Flatten and sort all items by time + const allItems = []; + Object.values(categories).forEach(list => allItems.push(...list)); + allItems.sort((a, b) => a.time.localeCompare(b.time)); + + // Filter for "Critical" keywords + const criticalItems = allItems.filter(i => + i.summary.toLowerCase().includes('fix') || + i.summary.toLowerCase().includes('patch') || + i.summary.toLowerCase().includes('create') || + i.summary.toLowerCase().includes('optimiz') + ); + + criticalItems.forEach(i => { + md += `- \`${i.time}\` (${i.component}): ${i.summary}\n`; + }); + + // Section 3: Package Adjustments + md += `\n## 3. Package & Documentation Adjustments\n`; + const comps = Object.keys(componentMap).sort(); + comps.forEach(comp => { + const count = new Set(componentMap[comp]).size; + md += `- **${comp}**: Received ${count} significant updates.\n`; + }); + + fs.writeFileSync(OUT_FILE, md); + console.log("Human report generated."); +} + +generateHumanReport(); + diff --git a/skills/capability-evolver/scripts/publish_public.js b/skills/capability-evolver/scripts/publish_public.js new file mode 100644 index 0000000..4ebc109 --- /dev/null +++ b/skills/capability-evolver/scripts/publish_public.js @@ -0,0 +1,614 @@ +const { execSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); + +function run(cmd, opts = {}) { + const { dryRun = false } = opts; + if (dryRun) { + process.stdout.write(`[dry-run] ${cmd}\n`); + return ''; + } + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function hasCommand(cmd) { + try { + if (process.platform === 'win32') { + const res = spawnSync('where', [cmd], { stdio: 'ignore' }); + return res.status === 0; + } + const res = spawnSync('which', [cmd], { stdio: 'ignore' }); + return res.status === 0; + } catch (e) { + return false; + } +} + +function resolveGhExecutable() { + if (hasCommand('gh')) return 'gh'; + const candidates = [ + 'C:\\Program Files\\GitHub CLI\\gh.exe', + 'C:\\Program Files (x86)\\GitHub CLI\\gh.exe', + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) return p; + } catch (e) { + // ignore + } + } + return null; +} + +function resolveClawhubExecutable() { + // On Windows, Node spawn/spawnSync does not always resolve PATHEXT the same way as shells. + // Prefer the explicit .cmd shim when available to avoid false "not logged in" detection. + if (process.platform === 'win32') { + if (hasCommand('clawhub.cmd')) return 'clawhub.cmd'; + if (hasCommand('clawhub')) return 'clawhub'; + } else { + if (hasCommand('clawhub')) return 'clawhub'; + } + // Common npm global bin location on Windows. + const candidates = [ + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.cmd', + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.exe', + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.ps1', + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) return p; + } catch (e) { + // ignore + } + } + return null; +} + +function canUseClawhub() { + const exe = resolveClawhubExecutable(); + if (!exe) return { ok: false, reason: 'clawhub CLI not found (install: npm i -g clawhub)' }; + return { ok: true, exe }; +} + +function isClawhubLoggedIn() { + const exe = resolveClawhubExecutable(); + if (!exe) return false; + try { + const res = spawnClawhub(exe, ['whoami'], { stdio: 'ignore' }); + return res.status === 0; + } catch (e) { + return false; + } +} + +function spawnClawhub(exe, args, options) { + // On Windows, directly spawning a .cmd can be flaky; using cmd.exe preserves argument parsing. + // (Using shell:true can break clap/commander style option parsing for some CLIs.) + const opts = options || {}; + if (process.platform === 'win32' && typeof exe === 'string') { + const lower = exe.toLowerCase(); + if (lower.endsWith('.cmd')) { + return spawnSync('cmd.exe', ['/d', '/s', '/c', exe, ...(args || [])], opts); + } + } + return spawnSync(exe, args || [], opts); +} + +function publishToClawhub({ skillDir, slug, name, version, changelog, tags, dryRun }) { + const ok = canUseClawhub(); + if (!ok.ok) throw new Error(ok.reason); + + // Idempotency: if this version already exists on ClawHub, skip publishing. + try { + const inspect = spawnClawhub(ok.exe, ['inspect', slug, '--version', version], { stdio: 'ignore' }); + if (inspect.status === 0) { + process.stdout.write(`ClawHub already has ${slug}@${version}. Skipping.\n`); + return; + } + } catch (e) { + // ignore inspect failures; publish will surface errors if needed + } + + if (!dryRun && !isClawhubLoggedIn()) { + throw new Error('Not logged in to ClawHub. Run: clawhub login'); + } + + const args = ['publish', skillDir, '--slug', slug, '--name', name, '--version', version]; + if (changelog) args.push('--changelog', changelog); + if (tags) args.push('--tags', tags); + + if (dryRun) { + process.stdout.write(`[dry-run] ${ok.exe} ${args.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')}\n`); + return; + } + + // Capture output to handle "version already exists" idempotently. + const res = spawnClawhub(ok.exe, args, { encoding: 'utf8' }); + const out = `${res.stdout || ''}\n${res.stderr || ''}`.trim(); + + if (res.status === 0) { + if (out) process.stdout.write(out + '\n'); + return; + } + + // Some clawhub deployments do not support reliable "inspect" by slug. + // Treat "Version already exists" as success to make publishing idempotent. + if (/version already exists/i.test(out)) { + process.stdout.write(`ClawHub already has ${slug}@${version}. Skipping.\n`); + return; + } + + if (out) process.stderr.write(out + '\n'); + throw new Error(`clawhub publish failed for slug ${slug}`); +} + +function requireEnv(name, value) { + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } +} + +function ensureClean(dryRun) { + const status = run('git status --porcelain', { dryRun }); + if (!dryRun && status) { + throw new Error('Working tree is not clean. Commit or stash before publishing.'); + } +} + +function ensureBranch(expected, dryRun) { + const current = run('git rev-parse --abbrev-ref HEAD', { dryRun }) || expected; + if (!dryRun && current !== expected) { + throw new Error(`Current branch is ${current}. Expected ${expected}.`); + } +} + +function ensureRemote(remote, dryRun) { + try { + run(`git remote get-url ${remote}`, { dryRun }); + } catch (e) { + throw new Error(`Remote "${remote}" not found. Add it manually before running this script.`); + } +} + +function ensureTagAvailable(tag, dryRun) { + if (!tag) return; + const exists = run(`git tag --list ${tag}`, { dryRun }); + if (!dryRun && exists) { + throw new Error(`Tag ${tag} already exists.`); + } +} + +function ensureDir(dir, dryRun) { + if (dryRun) return; + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function rmDir(dir, dryRun) { + if (dryRun) return; + if (!fs.existsSync(dir)) return; + fs.rmSync(dir, { recursive: true, force: true }); +} + +function copyDir(src, dest, dryRun) { + if (dryRun) return; + if (!fs.existsSync(src)) throw new Error(`Missing build output dir: ${src}`); + ensureDir(dest, dryRun); + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const ent of entries) { + const s = path.join(src, ent.name); + const d = path.join(dest, ent.name); + if (ent.isDirectory()) copyDir(s, d, dryRun); + else if (ent.isFile()) { + ensureDir(path.dirname(d), dryRun); + fs.copyFileSync(s, d); + } + } +} + +function createReleaseWithGh({ repo, tag, title, notes, notesFile, dryRun }) { + if (!repo || !tag) return; + const ghExe = resolveGhExecutable(); + if (!ghExe) { + throw new Error('gh CLI not found. Install GitHub CLI or provide a GitHub token for API-based release creation.'); + } + const args = ['release', 'create', tag, '--repo', repo]; + if (title) args.push('-t', title); + if (notesFile) args.push('-F', notesFile); + else if (notes) args.push('-n', notes); + else args.push('-n', 'Release created by publish script.'); + + if (dryRun) { + process.stdout.write(`[dry-run] ${ghExe} ${args.join(' ')}\n`); + return; + } + + const res = spawnSync(ghExe, args, { stdio: 'inherit' }); + if (res.status !== 0) { + throw new Error('gh release create failed'); + } +} + +function canUseGhForRelease() { + const ghExe = resolveGhExecutable(); + if (!ghExe) return { ok: false, reason: 'gh CLI not found' }; + try { + // Non-interactive check: returns 0 when authenticated. + const res = spawnSync(ghExe, ['auth', 'status', '-h', 'github.com'], { stdio: 'ignore' }); + if (res.status === 0) return { ok: true }; + return { ok: false, reason: 'gh not authenticated (run: gh auth login)' }; + } catch (e) { + return { ok: false, reason: 'failed to check gh auth status' }; + } +} + +function getGithubToken() { + return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT || ''; +} + +function readReleaseNotes(notes, notesFile) { + if (notesFile) { + try { + return fs.readFileSync(notesFile, 'utf8'); + } catch (e) { + throw new Error(`Failed to read RELEASE_NOTES_FILE: ${notesFile}`); + } + } + if (notes) return String(notes); + return ''; +} + +function githubRequestJson({ method, repo, apiPath, token, body, dryRun }) { + if (dryRun) { + process.stdout.write(`[dry-run] GitHub API ${method} ${repo} ${apiPath}\n`); + return Promise.resolve({ status: 200, json: null }); + } + + const data = body ? Buffer.from(JSON.stringify(body)) : null; + const opts = { + method, + hostname: 'api.github.com', + path: `/repos/${repo}${apiPath}`, + headers: { + 'User-Agent': 'evolver-publish-script', + Accept: 'application/vnd.github+json', + ...(token ? { Authorization: `token ${token}` } : {}), + ...(data ? { 'Content-Type': 'application/json', 'Content-Length': String(data.length) } : {}), + }, + }; + + return new Promise((resolve, reject) => { + const req = https.request(opts, res => { + let raw = ''; + res.setEncoding('utf8'); + res.on('data', chunk => (raw += chunk)); + res.on('end', () => { + let json = null; + try { + json = raw ? JSON.parse(raw) : null; + } catch (e) { + json = null; + } + resolve({ status: res.statusCode || 0, json, raw }); + }); + }); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function ensureReleaseWithApi({ repo, tag, title, notes, notesFile, dryRun }) { + if (!repo || !tag) return; + + const token = getGithubToken(); + if (!dryRun) { + requireEnv('GITHUB_TOKEN (or GH_TOKEN/GITHUB_PAT)', token); + } + + // If release already exists, skip. + const existing = await githubRequestJson({ + method: 'GET', + repo, + apiPath: `/releases/tags/${encodeURIComponent(tag)}`, + token, + dryRun, + }); + + if (!dryRun && existing.status === 200) { + process.stdout.write(`Release already exists for tag ${tag}. Skipping.\n`); + return; + } + + const bodyText = readReleaseNotes(notes, notesFile) || 'Release created by publish script.'; + const payload = { + tag_name: tag, + name: title || tag, + body: bodyText, + draft: false, + prerelease: false, + }; + + const created = await githubRequestJson({ + method: 'POST', + repo, + apiPath: '/releases', + token, + body: payload, + dryRun, + }); + + if (!dryRun && (created.status < 200 || created.status >= 300)) { + const msg = (created.json && created.json.message) || created.raw || 'Unknown error'; + throw new Error(`Failed to create GitHub Release (${created.status}): ${msg}`); + } + + process.stdout.write(`Created GitHub Release for tag ${tag}\n`); +} + +// Collect unique external contributors from private repo commits since the last release. +// Returns an array of "Name " strings suitable for Co-authored-by trailers. +// GitHub counts Co-authored-by toward the Contributors graph. +function getContributorsSinceLastRelease() { + const EXCLUDED = new Set([ + 'evolver-publish@local', + 'evolver@local', + 'openclaw@users.noreply.github.com', + ]); + + try { + let baseCommit = ''; + try { + baseCommit = execSync( + 'git log -n 1 --pretty=%H --grep="chore(release): prepare v"', + { encoding: 'utf8', cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] } + ).trim(); + } catch (_) {} + + const range = baseCommit ? `${baseCommit}..HEAD` : '-30'; + const raw = execSync( + `git log ${range} --pretty="%aN <%aE>"`, + { encoding: 'utf8', cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] } + ).trim(); + + if (!raw) return []; + + const seen = new Set(); + const contributors = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const emailMatch = trimmed.match(/<([^>]+)>/); + const email = emailMatch ? emailMatch[1].toLowerCase() : ''; + if (EXCLUDED.has(email)) continue; + if (seen.has(email)) continue; + seen.add(email); + contributors.push(trimmed); + } + return contributors; + } catch (_) { + return []; + } +} + +function main() { + const dryRun = String(process.env.DRY_RUN || '').toLowerCase() === 'true'; + + const sourceBranch = process.env.SOURCE_BRANCH || 'main'; + const publicRemote = process.env.PUBLIC_REMOTE || 'public'; + const publicBranch = process.env.PUBLIC_BRANCH || 'main'; + const publicRepo = process.env.PUBLIC_REPO || ''; + const outDir = process.env.PUBLIC_OUT_DIR || 'dist-public'; + const useBuildOutput = String(process.env.PUBLIC_USE_BUILD_OUTPUT || 'true').toLowerCase() === 'true'; + const releaseOnly = String(process.env.PUBLIC_RELEASE_ONLY || '').toLowerCase() === 'true'; + + const clawhubSkip = String(process.env.CLAWHUB_SKIP || '').toLowerCase() === 'true'; + const clawhubPublish = String(process.env.CLAWHUB_PUBLISH || '').toLowerCase() === 'false' ? false : !clawhubSkip; + // Workaround for registry redirect/auth issues: default to the www endpoint. + const clawhubRegistry = process.env.CLAWHUB_REGISTRY || 'https://www.clawhub.ai'; + + // If publishing build output, require a repo URL or GH repo slug for cloning. + if (useBuildOutput) { + requireEnv('PUBLIC_REPO', publicRepo); + } + + let releaseTag = process.env.RELEASE_TAG || ''; + let releaseTitle = process.env.RELEASE_TITLE || ''; + const releaseNotes = process.env.RELEASE_NOTES || ''; + const releaseNotesFile = process.env.RELEASE_NOTES_FILE || ''; + const releaseSkip = String(process.env.RELEASE_SKIP || '').toLowerCase() === 'true'; + // Default behavior: create release unless explicitly skipped. + // Backward compatibility: RELEASE_CREATE=true forces creation. + // Note: RELEASE_CREATE=false is ignored; use RELEASE_SKIP=true instead. + const releaseCreate = String(process.env.RELEASE_CREATE || '').toLowerCase() === 'true' ? true : !releaseSkip; + const releaseUseGh = String(process.env.RELEASE_USE_GH || '').toLowerCase() === 'true'; + + // If not provided, infer from build output package.json version. + if (!releaseTag && useBuildOutput) { + try { + const builtPkg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), outDir, 'package.json'), 'utf8')); + if (builtPkg && builtPkg.version) releaseTag = `v${builtPkg.version}`; + if (!releaseTitle && releaseTag) releaseTitle = releaseTag; + } catch (e) {} + } + + const releaseVersion = String(releaseTag || '').startsWith('v') ? String(releaseTag).slice(1) : ''; + + // Fail fast on missing release prerequisites to avoid half-publishing. + // Strategy: + // - If RELEASE_USE_GH=true: require gh + auth + // - Else: prefer gh+auth; fallback to API token; else fail + let releaseMode = 'none'; + if (releaseCreate && releaseTag) { + if (releaseUseGh) { + const ghOk = canUseGhForRelease(); + if (!dryRun && !ghOk.ok) { + throw new Error(`Cannot create release via gh: ${ghOk.reason}`); + } + releaseMode = 'gh'; + } else { + const ghOk = canUseGhForRelease(); + if (ghOk.ok) { + releaseMode = 'gh'; + } else { + const token = getGithubToken(); + if (!dryRun && !token) { + throw new Error( + 'Cannot create GitHub Release: neither gh (installed+authenticated) nor GITHUB_TOKEN (or GH_TOKEN/GITHUB_PAT) is available.' + ); + } + releaseMode = 'api'; + } + } + } + + // In release-only mode we do not push code or tags, only create a GitHub Release for an existing tag. + if (!releaseOnly) { + ensureClean(dryRun); + ensureBranch(sourceBranch, dryRun); + ensureTagAvailable(releaseTag, dryRun); + } else { + requireEnv('RELEASE_TAG', releaseTag); + } + + if (!releaseOnly) { + if (!useBuildOutput) { + ensureRemote(publicRemote, dryRun); + run(`git push ${publicRemote} ${sourceBranch}:${publicBranch}`, { dryRun }); + } else { + const tmpBase = path.join(os.tmpdir(), 'evolver-public-publish'); + const tmpRepoDir = path.join(tmpBase, `repo_${Date.now()}`); + const buildAbs = path.resolve(process.cwd(), outDir); + + rmDir(tmpRepoDir, dryRun); + ensureDir(tmpRepoDir, dryRun); + + run(`git clone --depth 1 https://github.com/${publicRepo}.git "${tmpRepoDir}"`, { dryRun }); + run(`git -C "${tmpRepoDir}" checkout -B ${publicBranch}`, { dryRun }); + + // Replace repo contents with build output (except .git) + if (!dryRun) { + const entries = fs.readdirSync(tmpRepoDir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name === '.git') continue; + fs.rmSync(path.join(tmpRepoDir, ent.name), { recursive: true, force: true }); + } + } + copyDir(buildAbs, tmpRepoDir, dryRun); + + run(`git -C "${tmpRepoDir}" add -A`, { dryRun }); + const msg = releaseTag ? `Release ${releaseTag}` : `Publish build output`; + + // If build output is identical to current public branch, skip commit/push. + const pending = run(`git -C "${tmpRepoDir}" status --porcelain`, { dryRun }); + if (!dryRun && !pending) { + process.stdout.write('Public repo already matches build output. Skipping commit/push.\n'); + } else { + const contributors = getContributorsSinceLastRelease(); + let commitMsg = msg.replace(/"/g, '\\"'); + if (contributors.length > 0) { + const trailers = contributors.map(c => `Co-authored-by: ${c}`).join('\n'); + commitMsg += `\n\n${trailers.replace(/"/g, '\\"')}`; + process.stdout.write(`Including ${contributors.length} contributor(s) in publish commit.\n`); + } + run( + `git -C "${tmpRepoDir}" -c user.name="evolver-publish" -c user.email="evolver-publish@local" commit -m "${commitMsg}"`, + { dryRun } + ); + run(`git -C "${tmpRepoDir}" push origin ${publicBranch}`, { dryRun }); + } + + if (releaseTag) { + const tagMsg = releaseTitle || `Release ${releaseTag}`; + // If tag already exists in the public repo, do not recreate it. + try { + run(`git -C "${tmpRepoDir}" fetch --tags`, { dryRun }); + const exists = run(`git -C "${tmpRepoDir}" tag --list ${releaseTag}`, { dryRun }); + if (!dryRun && exists) { + process.stdout.write(`Tag ${releaseTag} already exists in public repo. Skipping tag creation.\n`); + } else { + run(`git -C "${tmpRepoDir}" tag -a ${releaseTag} -m "${tagMsg.replace(/"/g, '\\"')}"`, { dryRun }); + run(`git -C "${tmpRepoDir}" push origin ${releaseTag}`, { dryRun }); + } + } catch (e) { + // If tag operations fail, rethrow to avoid publishing a release without a tag. + throw e; + } + } + } + + if (releaseTag) { + if (!useBuildOutput) { + const msg = releaseTitle || `Release ${releaseTag}`; + run(`git tag -a ${releaseTag} -m "${msg.replace(/"/g, '\\"')}"`, { dryRun }); + run(`git push ${publicRemote} ${releaseTag}`, { dryRun }); + } + } + } + + if (releaseCreate) { + if (releaseMode === 'gh') { + createReleaseWithGh({ + repo: publicRepo, + tag: releaseTag, + title: releaseTitle, + notes: releaseNotes, + notesFile: releaseNotesFile, + dryRun, + }); + } else if (releaseMode === 'api') { + return ensureReleaseWithApi({ + repo: publicRepo, + tag: releaseTag, + title: releaseTitle, + notes: releaseNotes, + notesFile: releaseNotesFile, + dryRun, + }); + } + } + + // Publish to ClawHub after GitHub release succeeds (default enabled). + if (clawhubPublish && releaseVersion) { + process.env.CLAWHUB_REGISTRY = clawhubRegistry; + + const skillDir = useBuildOutput ? path.resolve(process.cwd(), outDir) : process.cwd(); + const changelog = releaseTitle ? `GitHub Release ${releaseTitle}` : `GitHub Release ${releaseTag}`; + + publishToClawhub({ + skillDir, + slug: 'evolver', + name: 'Evolver', + version: releaseVersion, + changelog, + tags: 'latest', + dryRun, + }); + + publishToClawhub({ + skillDir, + slug: 'capability-evolver', + name: 'Capability Evolver', + version: releaseVersion, + changelog, + tags: 'latest', + dryRun, + }); + } +} + +try { + const maybePromise = main(); + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.catch(e => { + process.stderr.write(`${e.message}\n`); + process.exit(1); + }); + } +} catch (e) { + process.stderr.write(`${e.message}\n`); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/recover_loop.js b/skills/capability-evolver/scripts/recover_loop.js new file mode 100644 index 0000000..5e494f3 --- /dev/null +++ b/skills/capability-evolver/scripts/recover_loop.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function exists(p) { + try { + return fs.existsSync(p); + } catch (e) { + return false; + } +} + +function sleepMs(ms) { + const n = Number(ms); + const t = Number.isFinite(n) ? Math.max(0, n) : 0; + if (t <= 0) return; + spawnSync('sleep', [String(Math.ceil(t / 1000))], { stdio: 'ignore' }); +} + +function resolveWorkspaceRoot() { + // In OpenClaw exec, cwd is usually the workspace root. + // Keep it simple: do not try to walk up arbitrarily. + return process.cwd(); +} + +function resolveEvolverEntry(workspaceRoot) { + const candidates = [ + path.join(workspaceRoot, 'skills', 'evolver', 'index.js'), + path.join(workspaceRoot, 'skills', 'capability-evolver', 'index.js'), + ]; + for (const p of candidates) { + if (exists(p)) return p; + } + return null; +} + +function main() { + const waitMs = parseInt(String(process.env.EVOLVER_RECOVER_WAIT_MS || '10000'), 10); + const wait = Number.isFinite(waitMs) ? Math.max(0, waitMs) : 10000; + + console.log(`[RECOVERY] Waiting ${wait}ms before restart...`); + sleepMs(wait); + + const workspaceRoot = resolveWorkspaceRoot(); + const entry = resolveEvolverEntry(workspaceRoot); + if (!entry) { + console.error('[RECOVERY] Failed: cannot locate evolver entry under skills/.'); + process.exit(2); + } + + console.log(`[RECOVERY] Restarting loop via ${path.relative(workspaceRoot, entry)} ...`); + const r = spawnSync(process.execPath, [entry, '--loop'], { stdio: 'inherit' }); + process.exit(typeof r.status === 'number' ? r.status : 1); +} + +if (require.main === module) { + main(); +} + diff --git a/skills/capability-evolver/scripts/suggest_version.js b/skills/capability-evolver/scripts/suggest_version.js new file mode 100644 index 0000000..49c4177 --- /dev/null +++ b/skills/capability-evolver/scripts/suggest_version.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function parseSemver(v) { + const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; +} + +function bumpSemver(base, bump) { + const v = parseSemver(base); + if (!v) return null; + if (bump === 'major') return `${v.major + 1}.0.0`; + if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`; + if (bump === 'patch') return `${v.major}.${v.minor}.${v.patch + 1}`; + return `${v.major}.${v.minor}.${v.patch}`; +} + +function git(cmd) { + return execSync(cmd, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); +} + +function getBaseReleaseCommit() { + try { + const hash = git('git log -n 1 --pretty=%H --grep="chore(release): prepare v"'); + return hash || null; + } catch (e) { + return null; + } +} + +function getCommitSubjectsSince(baseCommit) { + try { + if (!baseCommit) { + const out = git('git log -n 30 --pretty=%s'); + return out ? out.split('\n').filter(Boolean) : []; + } + const out = git(`git log ${baseCommit}..HEAD --pretty=%s`); + return out ? out.split('\n').filter(Boolean) : []; + } catch (e) { + return []; + } +} + +function inferBumpFromSubjects(subjects) { + const subs = (subjects || []).map(s => String(s)); + const hasBreaking = subs.some(s => /\bBREAKING CHANGE\b/i.test(s) || /^[a-z]+(\(.+\))?!:/.test(s)); + if (hasBreaking) return { bump: 'major', reason: 'breaking change marker in commit subject' }; + + const hasFeat = subs.some(s => /^feat(\(.+\))?:/i.test(s)); + if (hasFeat) return { bump: 'minor', reason: 'feature commit detected (feat:)' }; + + const hasFix = subs.some(s => /^(fix|perf)(\(.+\))?:/i.test(s)); + if (hasFix) return { bump: 'patch', reason: 'fix/perf commit detected' }; + + if (subs.length === 0) return { bump: 'none', reason: 'no commits since base release commit' }; + return { bump: 'patch', reason: 'default to patch for non-breaking changes' }; +} + +function main() { + const pkgPath = path.join(REPO_ROOT, 'package.json'); + const baseVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; + + const baseCommit = getBaseReleaseCommit(); + const subjects = getCommitSubjectsSince(baseCommit); + const decision = inferBumpFromSubjects(subjects); + const suggestedVersion = decision.bump === 'none' ? baseVersion : bumpSemver(baseVersion, decision.bump); + + const out = { baseVersion, baseCommit, subjects, decision, suggestedVersion }; + const memDir = path.join(REPO_ROOT, 'memory'); + ensureDir(memDir); + fs.writeFileSync(path.join(memDir, 'semver_suggestion.json'), JSON.stringify(out, null, 2) + '\n', 'utf8'); + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); +} + +try { + main(); +} catch (e) { + process.stderr.write(`${e.message}\n`); + process.exit(1); +} + diff --git a/skills/capability-evolver/scripts/validate-modules.js b/skills/capability-evolver/scripts/validate-modules.js new file mode 100644 index 0000000..752a5ca --- /dev/null +++ b/skills/capability-evolver/scripts/validate-modules.js @@ -0,0 +1,8 @@ +// Usage: node scripts/validate-modules.js ./src/evolve ./src/gep/solidify +// Requires each module to verify it loads without errors. +// Paths are resolved relative to cwd (repo root), not this script's location. +const path = require('path'); +const modules = process.argv.slice(2); +if (!modules.length) { console.error('No modules specified'); process.exit(1); } +for (const m of modules) { require(path.resolve(m)); } +console.log('ok'); diff --git a/skills/capability-evolver/src/canary.js b/skills/capability-evolver/src/canary.js new file mode 100644 index 0000000..389a0e5 --- /dev/null +++ b/skills/capability-evolver/src/canary.js @@ -0,0 +1,13 @@ +// Canary script: run in a forked child process to verify index.js loads +// without crashing. Exit 0 = safe, non-zero = broken. +// +// This is the last safety net before solidify commits an evolution. +// If a patch broke index.js (syntax error, missing require, etc.), +// the canary catches it BEFORE the daemon restarts with broken code. +try { + require('../index.js'); + process.exit(0); +} catch (e) { + process.stderr.write(String(e.message || e).slice(0, 500)); + process.exit(1); +} diff --git a/skills/capability-evolver/src/evolve.js b/skills/capability-evolver/src/evolve.js new file mode 100644 index 0000000..79845ec --- /dev/null +++ b/skills/capability-evolver/src/evolve.js @@ -0,0 +1,1571 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); +const { getRepoRoot, getMemoryDir, getSessionScope } = require('./gep/paths'); +const { extractSignals } = require('./gep/signals'); +const { + loadGenes, + loadCapsules, + readAllEvents, + getLastEventId, + appendCandidateJsonl, + readRecentCandidates, + readRecentExternalCandidates, + readRecentFailedCapsules, + ensureAssetFiles, +} = require('./gep/assetStore'); +const { selectGeneAndCapsule, matchPatternToSignals } = require('./gep/selector'); +const { buildGepPrompt, buildReusePrompt, buildHubMatchedBlock } = require('./gep/prompt'); +const { hubSearch } = require('./gep/hubSearch'); +const { extractCapabilityCandidates, renderCandidatesPreview } = require('./gep/candidates'); +const memoryAdapter = require('./gep/memoryGraphAdapter'); +const { + getAdvice: getMemoryAdvice, + recordSignalSnapshot, + recordHypothesis, + recordAttempt, + recordOutcome: recordOutcomeFromState, + memoryGraphPath, +} = memoryAdapter; +const { readStateForSolidify, writeStateForSolidify } = require('./gep/solidify'); +const { fetchTasks, selectBestTask, claimTask, taskToSignals } = require('./gep/taskReceiver'); +const { generateQuestions } = require('./gep/questionGenerator'); +const { buildMutation, isHighRiskMutationAllowed } = require('./gep/mutation'); +const { selectPersonalityForRun } = require('./gep/personality'); +const { clip, writePromptArtifact, renderSessionsSpawnCall } = require('./gep/bridge'); +const { getEvolutionDir } = require('./gep/paths'); + +const REPO_ROOT = getRepoRoot(); + +// Load environment variables from repo root +try { + require('dotenv').config({ path: path.join(REPO_ROOT, '.env'), quiet: true }); +} catch (e) { + // dotenv might not be installed or .env missing, proceed gracefully +} + +// Configuration from CLI flags or Env +const ARGS = process.argv.slice(2); +const IS_REVIEW_MODE = ARGS.includes('--review'); +const IS_DRY_RUN = ARGS.includes('--dry-run'); +const IS_RANDOM_DRIFT = ARGS.includes('--drift') || String(process.env.RANDOM_DRIFT || '').toLowerCase() === 'true'; + +// Default Configuration +const MEMORY_DIR = getMemoryDir(); +const AGENT_NAME = process.env.AGENT_NAME || 'main'; +const AGENT_SESSIONS_DIR = path.join(os.homedir(), `.openclaw/agents/${AGENT_NAME}/sessions`); +const TODAY_LOG = path.join(MEMORY_DIR, new Date().toISOString().split('T')[0] + '.md'); + +// Ensure memory directory exists so state/cache writes work. +try { + if (!fs.existsSync(MEMORY_DIR)) fs.mkdirSync(MEMORY_DIR, { recursive: true }); +} catch (e) {} + +function formatSessionLog(jsonlContent) { + const result = []; + const lines = jsonlContent.split('\n'); + let lastLine = ''; + let repeatCount = 0; + + const flushRepeats = () => { + if (repeatCount > 0) { + result.push(` ... [Repeated ${repeatCount} times] ...`); + repeatCount = 0; + } + }; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + let entry = ''; + + if (data.type === 'message' && data.message) { + const role = (data.message.role || 'unknown').toUpperCase(); + let content = ''; + if (Array.isArray(data.message.content)) { + content = data.message.content + .map(c => { + if (c.type === 'text') return c.text; + if (c.type === 'toolCall') return `[TOOL: ${c.name}]`; + return ''; + }) + .join(' '); + } else if (typeof data.message.content === 'string') { + content = data.message.content; + } else { + content = JSON.stringify(data.message.content); + } + + // Capture LLM errors from errorMessage field (e.g. "Unsupported MIME type: image/gif") + if (data.message.errorMessage) { + const errMsg = typeof data.message.errorMessage === 'string' + ? data.message.errorMessage + : JSON.stringify(data.message.errorMessage); + content = `[LLM ERROR] ${errMsg.replace(/\n+/g, ' ').slice(0, 300)}`; + } + + // Filter: Skip Heartbeats to save noise + if (content.trim() === 'HEARTBEAT_OK') continue; + if (content.includes('NO_REPLY') && !data.message.errorMessage) continue; + + // Clean up newlines for compact reading + content = content.replace(/\n+/g, ' ').slice(0, 300); + entry = `**${role}**: ${content}`; + } else if (data.type === 'tool_result' || (data.message && data.message.role === 'toolResult')) { + // Filter: Skip generic success results or short uninformative ones + // Only show error or significant output + let resContent = ''; + + // Robust extraction: Handle structured tool results (e.g. sessions_spawn) that lack 'output' + if (data.tool_result) { + if (data.tool_result.output) { + resContent = data.tool_result.output; + } else { + resContent = JSON.stringify(data.tool_result); + } + } + + if (data.content) resContent = typeof data.content === 'string' ? data.content : JSON.stringify(data.content); + + if (resContent.length < 50 && (resContent.includes('success') || resContent.includes('done'))) continue; + if (resContent.trim() === '' || resContent === '{}') continue; + + // Improvement: Show snippet of result (especially errors) instead of hiding it + const preview = resContent.replace(/\n+/g, ' ').slice(0, 200); + entry = `[TOOL RESULT] ${preview}${resContent.length > 200 ? '...' : ''}`; + } + + if (entry) { + if (entry === lastLine) { + repeatCount++; + } else { + flushRepeats(); + result.push(entry); + lastLine = entry; + } + } + } catch (e) { + continue; + } + } + flushRepeats(); + return result.join('\n'); +} + +function readRealSessionLog() { + try { + if (!fs.existsSync(AGENT_SESSIONS_DIR)) return '[NO SESSION LOGS FOUND]'; + + const now = Date.now(); + const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours + const TARGET_BYTES = 120000; + const PER_SESSION_BYTES = 20000; // Read tail of each active session + + // Session scope isolation: when EVOLVER_SESSION_SCOPE is set, + // only read sessions whose filenames contain the scope identifier. + // This prevents cross-channel/cross-project memory contamination. + const sessionScope = getSessionScope(); + + // Find ALL active sessions (modified in last 24h), sorted newest first + let files = fs + .readdirSync(AGENT_SESSIONS_DIR) + .filter(f => f.endsWith('.jsonl') && !f.includes('.lock')) + .map(f => { + try { + const st = fs.statSync(path.join(AGENT_SESSIONS_DIR, f)); + return { name: f, time: st.mtime.getTime(), size: st.size }; + } catch (e) { + return null; + } + }) + .filter(f => f && (now - f.time) < ACTIVE_WINDOW_MS) + .sort((a, b) => b.time - a.time); // Newest first + + if (files.length === 0) return '[NO JSONL FILES]'; + + // Skip evolver's own sessions to avoid self-reference loops + let nonEvolverFiles = files.filter(f => !f.name.startsWith('evolver_hand_')); + + // Session scope filter: when scope is active, only include sessions + // whose filename contains the scope string (e.g., channel_123456.jsonl). + // If no sessions match the scope, fall back to all non-evolver sessions + // (graceful degradation -- better to evolve with global context than not at all). + if (sessionScope && nonEvolverFiles.length > 0) { + const scopeLower = sessionScope.toLowerCase(); + const scopedFiles = nonEvolverFiles.filter(f => f.name.toLowerCase().includes(scopeLower)); + if (scopedFiles.length > 0) { + nonEvolverFiles = scopedFiles; + console.log(`[SessionScope] Filtered to ${scopedFiles.length} session(s) matching scope "${sessionScope}".`); + } else { + console.log(`[SessionScope] No sessions match scope "${sessionScope}". Using all ${nonEvolverFiles.length} session(s) (fallback).`); + } + } + + const activeFiles = nonEvolverFiles.length > 0 ? nonEvolverFiles : files.slice(0, 1); + + // Read from multiple active sessions (up to 6) to get a full picture + const maxSessions = Math.min(activeFiles.length, 6); + const sections = []; + let totalBytes = 0; + + for (let i = 0; i < maxSessions && totalBytes < TARGET_BYTES; i++) { + const f = activeFiles[i]; + const bytesLeft = TARGET_BYTES - totalBytes; + const readSize = Math.min(PER_SESSION_BYTES, bytesLeft); + const raw = readRecentLog(path.join(AGENT_SESSIONS_DIR, f.name), readSize); + const formatted = formatSessionLog(raw); + if (formatted.trim()) { + sections.push(`--- SESSION (${f.name}) ---\n${formatted}`); + totalBytes += formatted.length; + } + } + + let content = sections.join('\n\n'); + + return content; + } catch (e) { + return `[ERROR READING SESSION LOGS: ${e.message}]`; + } +} + +function readRecentLog(filePath, size = 10000) { + try { + if (!fs.existsSync(filePath)) return `[MISSING] ${filePath}`; + const stats = fs.statSync(filePath); + const start = Math.max(0, stats.size - size); + const buffer = Buffer.alloc(stats.size - start); + const fd = fs.openSync(filePath, 'r'); + fs.readSync(fd, buffer, 0, buffer.length, start); + fs.closeSync(fd); + return buffer.toString('utf8'); + } catch (e) { + return `[ERROR READING ${filePath}: ${e.message}]`; + } +} + +function checkSystemHealth() { + const report = []; + try { + // Uptime & Node Version + const uptime = (os.uptime() / 3600).toFixed(1); + report.push(`Uptime: ${uptime}h`); + report.push(`Node: ${process.version}`); + + // Memory Usage (RSS) + const mem = process.memoryUsage(); + const rssMb = (mem.rss / 1024 / 1024).toFixed(1); + report.push(`Agent RSS: ${rssMb}MB`); + + // Optimization: Use native Node.js fs.statfsSync instead of spawning 'df' + if (fs.statfsSync) { + const stats = fs.statfsSync('/'); + const total = stats.blocks * stats.bsize; + const free = stats.bfree * stats.bsize; + const used = total - free; + const freeGb = (free / 1024 / 1024 / 1024).toFixed(1); + const usedPercent = Math.round((used / total) * 100); + report.push(`Disk: ${usedPercent}% (${freeGb}G free)`); + } + } catch (e) {} + + try { + if (process.platform === 'win32') { + const wmic = execSync('tasklist /FI "IMAGENAME eq node.exe" /NH', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + windowsHide: true, + }); + const count = wmic.split('\n').filter(l => l.trim() && !l.includes('INFO:')).length; + report.push(`Node Processes: ${count}`); + } else { + try { + const pgrep = execSync('pgrep -c node', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }); + report.push(`Node Processes: ${pgrep.trim()}`); + } catch (e) { + const ps = execSync('ps aux | grep node | grep -v grep | wc -l', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }); + report.push(`Node Processes: ${ps.trim()}`); + } + } + } catch (e) {} + + // Integration Health Checks (Env Vars) + try { + const issues = []; + + // Generic Integration Status Check (Decoupled) + if (process.env.INTEGRATION_STATUS_CMD) { + try { + const status = execSync(process.env.INTEGRATION_STATUS_CMD, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + windowsHide: true, + }); + if (status.trim()) issues.push(status.trim()); + } catch (e) {} + } + + if (issues.length > 0) { + report.push(`Integrations: ${issues.join(', ')}`); + } else { + report.push('Integrations: Nominal'); + } + } catch (e) {} + + return report.length ? report.join(' | ') : 'Health Check Unavailable'; +} + +function getMutationDirective(logContent) { + // Signal hints derived from recent logs. + const errorMatches = logContent.match(/\[ERROR|Error:|Exception:|FAIL|Failed|"isError":true/gi) || []; + const errorCount = errorMatches.length; + const isUnstable = errorCount > 2; + const recommendedIntent = isUnstable ? 'repair' : 'optimize'; + + return ` +[Signal Hints] +- recent_error_count: ${errorCount} +- stability: ${isUnstable ? 'unstable' : 'stable'} +- recommended_intent: ${recommendedIntent} +`; +} + +const STATE_FILE = path.join(getEvolutionDir(), 'evolution_state.json'); +const DORMANT_HYPOTHESIS_FILE = path.join(getEvolutionDir(), 'dormant_hypothesis.json'); +var DORMANT_TTL_MS = 3600 * 1000; + +function writeDormantHypothesis(data) { + try { + var dir = getEvolutionDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + var obj = Object.assign({}, data, { created_at: new Date().toISOString(), ttl_ms: DORMANT_TTL_MS }); + var tmp = DORMANT_HYPOTHESIS_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, DORMANT_HYPOTHESIS_FILE); + console.log('[DormantHypothesis] Saved partial state before backoff: ' + (data.backoff_reason || 'unknown')); + } catch (e) { + console.log('[DormantHypothesis] Write failed (non-fatal): ' + (e && e.message ? e.message : e)); + } +} + +function readDormantHypothesis() { + try { + if (!fs.existsSync(DORMANT_HYPOTHESIS_FILE)) return null; + var raw = fs.readFileSync(DORMANT_HYPOTHESIS_FILE, 'utf8'); + if (!raw.trim()) return null; + var obj = JSON.parse(raw); + var createdAt = obj.created_at ? new Date(obj.created_at).getTime() : 0; + var ttl = Number.isFinite(Number(obj.ttl_ms)) ? Number(obj.ttl_ms) : DORMANT_TTL_MS; + if (Date.now() - createdAt > ttl) { + clearDormantHypothesis(); + console.log('[DormantHypothesis] Expired (age: ' + Math.round((Date.now() - createdAt) / 1000) + 's). Discarded.'); + return null; + } + return obj; + } catch (e) { + return null; + } +} + +function clearDormantHypothesis() { + try { + if (fs.existsSync(DORMANT_HYPOTHESIS_FILE)) fs.unlinkSync(DORMANT_HYPOTHESIS_FILE); + } catch (e) {} +} +// Read MEMORY.md and USER.md from the WORKSPACE root (not the evolver plugin dir). +// This avoids symlink breakage if the target file is temporarily deleted. +const WORKSPACE_ROOT = process.env.OPENCLAW_WORKSPACE || path.resolve(REPO_ROOT, '../..'); +const ROOT_MEMORY = path.join(WORKSPACE_ROOT, 'MEMORY.md'); +const DIR_MEMORY = path.join(MEMORY_DIR, 'MEMORY.md'); +const MEMORY_FILE = fs.existsSync(ROOT_MEMORY) ? ROOT_MEMORY : (fs.existsSync(DIR_MEMORY) ? DIR_MEMORY : ROOT_MEMORY); +const USER_FILE = path.join(WORKSPACE_ROOT, 'USER.md'); + +function readMemorySnippet() { + try { + // Session scope isolation: when a scope is active, prefer scoped MEMORY.md + // at memory/scopes//MEMORY.md. Falls back to global MEMORY.md if + // scoped file doesn't exist (common: scoped MEMORY.md created on first evolution). + const scope = getSessionScope(); + let memFile = MEMORY_FILE; + if (scope) { + const scopedMemory = path.join(MEMORY_DIR, 'scopes', scope, 'MEMORY.md'); + if (fs.existsSync(scopedMemory)) { + memFile = scopedMemory; + console.log(`[SessionScope] Reading scoped MEMORY.md for "${scope}".`); + } else { + // First run with scope: global MEMORY.md will be used, but note it. + console.log(`[SessionScope] No scoped MEMORY.md for "${scope}". Using global MEMORY.md.`); + } + } + if (!fs.existsSync(memFile)) return '[MEMORY.md MISSING]'; + const content = fs.readFileSync(memFile, 'utf8'); + // Optimization: Increased limit from 2000 to 50000 for modern context windows + return content.length > 50000 + ? content.slice(0, 50000) + `\n... [TRUNCATED: ${content.length - 50000} chars remaining]` + : content; + } catch (e) { + return '[ERROR READING MEMORY.md]'; + } +} + +function readUserSnippet() { + try { + if (!fs.existsSync(USER_FILE)) return '[USER.md MISSING]'; + return fs.readFileSync(USER_FILE, 'utf8'); + } catch (e) { + return '[ERROR READING USER.md]'; + } +} + +function getNextCycleId() { + let state = { cycleCount: 0, lastRun: 0 }; + try { + if (fs.existsSync(STATE_FILE)) { + state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); + } + } catch (e) {} + + state.cycleCount = (state.cycleCount || 0) + 1; + state.lastRun = Date.now(); + + try { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + } catch (e) {} + + return String(state.cycleCount).padStart(4, '0'); +} + +function performMaintenance() { + // Auto-update check (rate-limited, non-fatal). + checkAndAutoUpdate(); + + try { + if (!fs.existsSync(AGENT_SESSIONS_DIR)) return; + + const files = fs.readdirSync(AGENT_SESSIONS_DIR).filter(f => f.endsWith('.jsonl')); + + // Clean up evolver's own hand sessions immediately. + // These are single-use executor sessions that must not accumulate, + // otherwise they pollute the agent's context and starve user conversations. + const evolverFiles = files.filter(f => f.startsWith('evolver_hand_')); + for (const f of evolverFiles) { + try { + fs.unlinkSync(path.join(AGENT_SESSIONS_DIR, f)); + } catch (_) {} + } + if (evolverFiles.length > 0) { + console.log(`[Maintenance] Cleaned ${evolverFiles.length} evolver hand session(s).`); + } + + // Archive old non-evolver sessions when count exceeds threshold. + const remaining = files.length - evolverFiles.length; + if (remaining < 100) return; + + console.log(`[Maintenance] Found ${remaining} session logs. Archiving old ones...`); + + const ARCHIVE_DIR = path.join(AGENT_SESSIONS_DIR, 'archive'); + if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true }); + + const fileStats = files + .filter(f => !f.startsWith('evolver_hand_')) + .map(f => { + try { + return { name: f, time: fs.statSync(path.join(AGENT_SESSIONS_DIR, f)).mtime.getTime() }; + } catch (e) { + return null; + } + }) + .filter(Boolean) + .sort((a, b) => a.time - b.time); + + const toArchive = fileStats.slice(0, fileStats.length - 50); + + for (const file of toArchive) { + const oldPath = path.join(AGENT_SESSIONS_DIR, file.name); + const newPath = path.join(ARCHIVE_DIR, file.name); + fs.renameSync(oldPath, newPath); + } + if (toArchive.length > 0) { + console.log(`[Maintenance] Archived ${toArchive.length} logs to ${ARCHIVE_DIR}`); + } + } catch (e) { + console.error(`[Maintenance] Error: ${e.message}`); + } +} + +// --- Auto-update: check for newer versions of evolver and wrapper on ClawHub --- +function checkAndAutoUpdate() { + try { + // Read config: default autoUpdate = true + const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); + let autoUpdate = true; + let intervalHours = 6; + try { + if (fs.existsSync(configPath)) { + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (cfg.evolver && cfg.evolver.autoUpdate === false) autoUpdate = false; + if (cfg.evolver && Number.isFinite(Number(cfg.evolver.autoUpdateIntervalHours))) { + intervalHours = Number(cfg.evolver.autoUpdateIntervalHours); + } + } + } catch (_) {} + + if (!autoUpdate) return; + + // Rate limit: only check once per interval + const stateFile = path.join(MEMORY_DIR, 'evolver_update_check.json'); + const now = Date.now(); + const intervalMs = intervalHours * 60 * 60 * 1000; + try { + if (fs.existsSync(stateFile)) { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (state.lastCheckedAt && (now - new Date(state.lastCheckedAt).getTime()) < intervalMs) { + return; // Too soon, skip + } + } + } catch (_) {} + + let clawhubBin = null; + const whichCmd = process.platform === 'win32' ? 'where clawhub' : 'which clawhub'; + const candidates = ['clawhub', path.join(os.homedir(), '.npm-global/bin/clawhub'), '/usr/local/bin/clawhub']; + for (const c of candidates) { + try { + if (c === 'clawhub') { + execSync(whichCmd, { stdio: 'ignore', timeout: 3000, windowsHide: true }); + clawhubBin = 'clawhub'; + break; + } + if (fs.existsSync(c)) { clawhubBin = c; break; } + } catch (_) {} + } + if (!clawhubBin) return; // No clawhub CLI available + + // Update evolver and feishu-evolver-wrapper + const slugs = ['evolver', 'feishu-evolver-wrapper']; + let updated = false; + for (const slug of slugs) { + try { + const out = execSync(`${clawhubBin} update ${slug} --force`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30000, + cwd: path.resolve(REPO_ROOT, '..'), + windowsHide: true, + }); + if (out && !out.includes('already up to date') && !out.includes('not installed')) { + console.log(`[AutoUpdate] ${slug}: ${out.trim().split('\n').pop()}`); + updated = true; + } + } catch (e) { + // Non-fatal: update failure should never block evolution + } + } + + // Write state + try { + const stateData = { + lastCheckedAt: new Date(now).toISOString(), + updated, + }; + fs.writeFileSync(stateFile, JSON.stringify(stateData, null, 2) + '\n'); + } catch (_) {} + + if (updated) { + console.log('[AutoUpdate] Skills updated. Changes will take effect on next wrapper restart.'); + } + } catch (e) { + // Entire auto-update is non-fatal + console.log(`[AutoUpdate] Check failed (non-fatal): ${e.message}`); + } +} + +function sleepMs(ms) { + const t = Number(ms); + const n = Number.isFinite(t) ? Math.max(0, t) : 0; + return new Promise(resolve => setTimeout(resolve, n)); +} + +// Check system load average via os.loadavg(). +// Returns { load1m, load5m, load15m }. Used for load-aware throttling. +function getSystemLoad() { + try { + const loadavg = os.loadavg(); + return { load1m: loadavg[0], load5m: loadavg[1], load15m: loadavg[2] }; + } catch (e) { + return { load1m: 0, load5m: 0, load15m: 0 }; + } +} + +// Calculate intelligent default load threshold based on CPU cores +// Rule of thumb: +// - Single-core: 0.8-1.0 (use 0.9) +// - Multi-core: cores x 0.8-1.0 (use 0.9) +// - Production: reserve 20% headroom for burst traffic +function getDefaultLoadMax() { + const cpuCount = os.cpus().length; + if (cpuCount === 1) { + return 0.9; + } else { + return cpuCount * 0.9; + } +} + +// Check how many agent sessions are actively being processed (modified in the last N minutes). +// If the agent is busy with user conversations, evolver should back off. +function getRecentActiveSessionCount(windowMs) { + try { + if (!fs.existsSync(AGENT_SESSIONS_DIR)) return 0; + const now = Date.now(); + const w = Number.isFinite(windowMs) ? windowMs : 10 * 60 * 1000; + return fs.readdirSync(AGENT_SESSIONS_DIR) + .filter(f => f.endsWith('.jsonl') && !f.includes('.lock') && !f.startsWith('evolver_hand_')) + .filter(f => { + try { return (now - fs.statSync(path.join(AGENT_SESSIONS_DIR, f)).mtimeMs) < w; } catch (_) { return false; } + }).length; + } catch (_) { return 0; } +} + +async function run() { + const bridgeEnabled = String(process.env.EVOLVE_BRIDGE || '').toLowerCase() !== 'false'; + const loopMode = ARGS.includes('--loop') || ARGS.includes('--mad-dog') || String(process.env.EVOLVE_LOOP || '').toLowerCase() === 'true'; + + // SAFEGUARD: If another evolver Hand Agent is already running, back off. + // Prevents race conditions when a wrapper restarts while the old Hand Agent + // is still executing. The Core yields instead of starting a competing cycle. + if (process.platform !== 'win32') { + try { + const _psRace = require('child_process').execSync( + 'ps aux | grep "evolver_hand_" | grep "openclaw.*agent" | grep -v grep', + { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] } + ).trim(); + if (_psRace && _psRace.length > 0) { + console.log('[Evolver] Another evolver Hand Agent is already running. Yielding this cycle.'); + return; + } + } catch (_) { + // grep exit 1 = no match = no conflict, safe to proceed + } + } + + // SAFEGUARD: If the agent has too many active user sessions, back off. + // Evolver must not starve user conversations by consuming model concurrency. + const QUEUE_MAX = Number.parseInt(process.env.EVOLVE_AGENT_QUEUE_MAX || '10', 10); + const QUEUE_BACKOFF_MS = Number.parseInt(process.env.EVOLVE_AGENT_QUEUE_BACKOFF_MS || '60000', 10); + const activeUserSessions = getRecentActiveSessionCount(10 * 60 * 1000); + if (activeUserSessions > QUEUE_MAX) { + console.log(`[Evolver] Agent has ${activeUserSessions} active user sessions (max ${QUEUE_MAX}). Backing off ${QUEUE_BACKOFF_MS}ms to avoid starving user conversations.`); + writeDormantHypothesis({ + backoff_reason: 'active_sessions_exceeded', + active_sessions: activeUserSessions, + queue_max: QUEUE_MAX, + }); + await sleepMs(QUEUE_BACKOFF_MS); + return; + } + + // SAFEGUARD: System load awareness. + // When system load is too high (e.g. too many concurrent processes, heavy I/O), + // back off to prevent the evolver from contributing to load spikes. + // Echo-MingXuan's Cycle #55 saw load spike from 0.02-0.50 to 1.30 before crash. + const LOAD_MAX = parseFloat(process.env.EVOLVE_LOAD_MAX || String(getDefaultLoadMax())); + const sysLoad = getSystemLoad(); + if (sysLoad.load1m > LOAD_MAX) { + console.log(`[Evolver] System load ${sysLoad.load1m.toFixed(2)} exceeds max ${LOAD_MAX.toFixed(1)} (auto-calculated for ${os.cpus().length} cores). Backing off ${QUEUE_BACKOFF_MS}ms.`); + writeDormantHypothesis({ + backoff_reason: 'system_load_exceeded', + system_load: { load1m: sysLoad.load1m, load5m: sysLoad.load5m, load15m: sysLoad.load15m }, + load_max: LOAD_MAX, + cpu_cores: os.cpus().length, + }); + await sleepMs(QUEUE_BACKOFF_MS); + return; + } + + // Loop gating: do not start a new cycle until the previous one is solidified. + // This prevents wrappers from "fast-cycling" the Brain without waiting for the Hand to finish. + if (bridgeEnabled && loopMode) { + try { + const st = readStateForSolidify(); + const lastRun = st && st.last_run ? st.last_run : null; + const lastSolid = st && st.last_solidify ? st.last_solidify : null; + if (lastRun && lastRun.run_id) { + const pending = !lastSolid || !lastSolid.run_id || String(lastSolid.run_id) !== String(lastRun.run_id); + if (pending) { + writeDormantHypothesis({ + backoff_reason: 'loop_gating_pending_solidify', + signals: lastRun && Array.isArray(lastRun.signals) ? lastRun.signals : [], + selected_gene_id: lastRun && lastRun.selected_gene_id ? lastRun.selected_gene_id : null, + mutation: lastRun && lastRun.mutation ? lastRun.mutation : null, + personality_state: lastRun && lastRun.personality_state ? lastRun.personality_state : null, + run_id: lastRun.run_id, + }); + const raw = process.env.EVOLVE_PENDING_SLEEP_MS || process.env.EVOLVE_MIN_INTERVAL || '120000'; + const n = parseInt(String(raw), 10); + const waitMs = Number.isFinite(n) ? Math.max(0, n) : 120000; + await sleepMs(waitMs); + return; + } + } + } catch (e) { + // If we cannot read state, proceed (fail open) to avoid deadlock. + } + } + + // Reset per-cycle env flags to prevent state leaking between cycles. + // In --loop mode, process.env persists across cycles. The circuit breaker + // below will re-set FORCE_INNOVATION if the condition still holds. + // CWD Recovery: If the working directory was deleted during a previous cycle + // (e.g., by git reset/restore or directory removal), process.cwd() throws + // ENOENT and ALL subsequent operations fail. Recover by chdir to REPO_ROOT. + try { + process.cwd(); + } catch (e) { + if (e && e.code === 'ENOENT') { + console.warn('[Evolver] CWD lost (ENOENT). Recovering to REPO_ROOT: ' + REPO_ROOT); + try { process.chdir(REPO_ROOT); } catch (e2) { + console.error('[Evolver] CWD recovery failed: ' + (e2 && e2.message ? e2.message : e2)); + throw e; + } + } else { + throw e; + } + } + + delete process.env.FORCE_INNOVATION; + + var dormantHypothesis = readDormantHypothesis(); + if (dormantHypothesis) { + console.log('[DormantHypothesis] Recovered partial state from previous backoff: ' + (dormantHypothesis.backoff_reason || 'unknown')); + clearDormantHypothesis(); + } + + const startTime = Date.now(); + console.log('Scanning session logs...'); + + // Ensure all GEP asset files exist before any operation. + // This prevents "No such file or directory" errors when external tools + // (grep, cat, etc.) reference optional append-only files like genes.jsonl. + try { ensureAssetFiles(); } catch (e) { + console.error(`[AssetInit] ensureAssetFiles failed (non-fatal): ${e.message}`); + } + + // Maintenance: Clean up old logs to keep directory scan fast + if (!IS_DRY_RUN) { + performMaintenance(); + } else { + console.log('[Maintenance] Skipped (dry-run mode).'); + } + + // --- Repair Loop Circuit Breaker --- + // Detect when the evolver is stuck in a "repair -> fail -> repair" cycle. + // If the last N events are all failed repairs with the same gene, force + // innovation intent to break out of the loop instead of retrying the same fix. + const REPAIR_LOOP_THRESHOLD = 3; + try { + const allEvents = readAllEvents(); + const recent = Array.isArray(allEvents) ? allEvents.slice(-REPAIR_LOOP_THRESHOLD) : []; + if (recent.length >= REPAIR_LOOP_THRESHOLD) { + const allRepairFailed = recent.every(e => + e && e.intent === 'repair' && + e.outcome && e.outcome.status === 'failed' + ); + if (allRepairFailed) { + const geneIds = recent.map(e => (e.genes_used && e.genes_used[0]) || 'unknown'); + const sameGene = geneIds.every(id => id === geneIds[0]); + console.warn(`[CircuitBreaker] Detected ${REPAIR_LOOP_THRESHOLD} consecutive failed repairs${sameGene ? ` (gene: ${geneIds[0]})` : ''}. Forcing innovation intent to break the loop.`); + // Set env flag that downstream code reads to force innovation + process.env.FORCE_INNOVATION = 'true'; + } + } + } catch (e) { + // Non-fatal: if we can't read events, proceed normally + console.error(`[CircuitBreaker] Check failed (non-fatal): ${e.message}`); + } + + const recentMasterLog = readRealSessionLog(); + const todayLog = readRecentLog(TODAY_LOG); + const memorySnippet = readMemorySnippet(); + const userSnippet = readUserSnippet(); + + const cycleNum = getNextCycleId(); + const cycleId = `Cycle #${cycleNum}`; + + // 2. Detect Workspace State & Local Overrides + // Logic: Default to generic reporting (message) + let fileList = ''; + const skillsDir = path.join(REPO_ROOT, 'skills'); + + // Default Reporting: Use generic `message` tool or `process.env.EVOLVE_REPORT_CMD` if set. + // This removes the hardcoded dependency on 'feishu-card' from the core logic. + let reportingDirective = `Report requirement: + - Use \`message\` tool. + - Title: Evolution ${cycleId} + - Status: [SUCCESS] + - Changes: Detail exactly what was improved.`; + + // Wrapper Injection Point: The wrapper can inject a custom reporting directive via ENV. + if (process.env.EVOLVE_REPORT_DIRECTIVE) { + reportingDirective = process.env.EVOLVE_REPORT_DIRECTIVE.replace('__CYCLE_ID__', cycleId); + } else if (process.env.EVOLVE_REPORT_CMD) { + reportingDirective = `Report requirement (custom): + - Execute the custom report command: + \`\`\` + ${process.env.EVOLVE_REPORT_CMD.replace('__CYCLE_ID__', cycleId)} + \`\`\` + - Ensure you pass the status and action details.`; + } + + // Handle Review Mode Flag (--review) + if (IS_REVIEW_MODE) { + reportingDirective += + '\n - REVIEW PAUSE: After generating the fix but BEFORE applying significant edits, ask the user for confirmation.'; + } + + const SKILLS_CACHE_FILE = path.join(MEMORY_DIR, 'skills_list_cache.json'); + + try { + if (fs.existsSync(skillsDir)) { + // Check cache validity (mtime of skills folder vs cache file) + let useCache = false; + const dirStats = fs.statSync(skillsDir); + if (fs.existsSync(SKILLS_CACHE_FILE)) { + const cacheStats = fs.statSync(SKILLS_CACHE_FILE); + const CACHE_TTL = 1000 * 60 * 60 * 6; // 6 Hours + const isFresh = Date.now() - cacheStats.mtimeMs < CACHE_TTL; + + // Use cache if it's fresh AND newer than the directory (structure change) + if (isFresh && cacheStats.mtimeMs > dirStats.mtimeMs) { + try { + const cached = JSON.parse(fs.readFileSync(SKILLS_CACHE_FILE, 'utf8')); + fileList = cached.list; + useCache = true; + } catch (e) {} + } + } + + if (!useCache) { + const skills = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => { + const name = dirent.name; + let desc = 'No description'; + try { + const pkg = require(path.join(skillsDir, name, 'package.json')); + if (pkg.description) desc = pkg.description.slice(0, 100) + (pkg.description.length > 100 ? '...' : ''); + } catch (e) { + try { + const skillMdPath = path.join(skillsDir, name, 'SKILL.md'); + if (fs.existsSync(skillMdPath)) { + const skillMd = fs.readFileSync(skillMdPath, 'utf8'); + // Strategy 1: YAML Frontmatter (description: ...) + const yamlMatch = skillMd.match(/^description:\s*(.*)$/m); + if (yamlMatch) { + desc = yamlMatch[1].trim(); + } else { + // Strategy 2: First non-header, non-empty line + const lines = skillMd.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if ( + trimmed && + !trimmed.startsWith('#') && + !trimmed.startsWith('---') && + !trimmed.startsWith('```') + ) { + desc = trimmed; + break; + } + } + } + if (desc.length > 100) desc = desc.slice(0, 100) + '...'; + } + } catch (e2) {} + } + return `- **${name}**: ${desc}`; + }); + fileList = skills.join('\n'); + + // Write cache + try { + fs.writeFileSync(SKILLS_CACHE_FILE, JSON.stringify({ list: fileList }, null, 2)); + } catch (e) {} + } + } + } catch (e) { + fileList = `Error listing skills: ${e.message}`; + } + + const mutationDirective = getMutationDirective(recentMasterLog); + const healthReport = checkSystemHealth(); + + // Feature: Mood Awareness (Mode E - Personalization) + let moodStatus = 'Mood: Unknown'; + try { + const moodFile = path.join(MEMORY_DIR, 'mood.json'); + if (fs.existsSync(moodFile)) { + const moodData = JSON.parse(fs.readFileSync(moodFile, 'utf8')); + moodStatus = `Mood: ${moodData.current_mood || 'Neutral'} (Intensity: ${moodData.intensity || 0})`; + } + } catch (e) {} + + const scanTime = Date.now() - startTime; + const memorySize = fs.existsSync(MEMORY_FILE) ? fs.statSync(MEMORY_FILE).size : 0; + + let syncDirective = 'Workspace sync: optional/disabled in this environment.'; + + // Check for git-sync skill availability + const hasGitSync = fs.existsSync(path.join(skillsDir, 'git-sync')); + if (hasGitSync) { + syncDirective = 'Workspace sync: run skills/git-sync/sync.sh "Evolution: Workspace Sync"'; + } + + const genes = loadGenes(); + const capsules = loadCapsules(); + const recentEvents = (() => { + try { + const all = readAllEvents(); + return Array.isArray(all) ? all.filter(e => e && e.type === 'EvolutionEvent').slice(-80) : []; + } catch (e) { + return []; + } + })(); + const signals = extractSignals({ + recentSessionTranscript: recentMasterLog, + todayLog, + memorySnippet, + userSnippet, + recentEvents, + }); + + if (dormantHypothesis && Array.isArray(dormantHypothesis.signals) && dormantHypothesis.signals.length > 0) { + var dormantSignals = dormantHypothesis.signals; + var injected = 0; + for (var dsi = 0; dsi < dormantSignals.length; dsi++) { + if (!signals.includes(dormantSignals[dsi])) { + signals.push(dormantSignals[dsi]); + injected++; + } + } + if (injected > 0) { + console.log('[DormantHypothesis] Injected ' + injected + ' signal(s) from previous interrupted cycle.'); + } + } + + // --- Hub Task Auto-Claim (with proactive questions) --- + // Generate questions from current context, piggyback them on the fetch call, + // then pick the best task and auto-claim it. + let activeTask = null; + let proactiveQuestions = []; + try { + proactiveQuestions = generateQuestions({ + signals, + recentEvents, + sessionTranscript: recentMasterLog, + memorySnippet: memorySnippet, + }); + if (proactiveQuestions.length > 0) { + console.log(`[QuestionGenerator] Generated ${proactiveQuestions.length} proactive question(s).`); + } + } catch (e) { + console.log(`[QuestionGenerator] Generation failed (non-fatal): ${e.message}`); + } + + // LessonL: lessons received from Hub during fetch + let hubLessons = []; + + try { + const fetchResult = await fetchTasks({ questions: proactiveQuestions }); + const hubTasks = fetchResult.tasks || []; + + if (fetchResult.questions_created && fetchResult.questions_created.length > 0) { + const created = fetchResult.questions_created.filter(function(q) { return !q.error; }); + const failed = fetchResult.questions_created.filter(function(q) { return q.error; }); + if (created.length > 0) { + console.log(`[QuestionGenerator] Hub accepted ${created.length} question(s) as bounties.`); + } + if (failed.length > 0) { + console.log(`[QuestionGenerator] Hub rejected ${failed.length} question(s): ${failed.map(function(q) { return q.error; }).join(', ')}`); + } + } + + // LessonL: capture relevant lessons from Hub + if (Array.isArray(fetchResult.relevant_lessons) && fetchResult.relevant_lessons.length > 0) { + hubLessons = fetchResult.relevant_lessons; + console.log(`[LessonBank] Received ${hubLessons.length} lesson(s) from ecosystem.`); + } + + if (hubTasks.length > 0) { + let taskMemoryEvents = []; + try { + const { tryReadMemoryGraphEvents } = require('./gep/memoryGraph'); + taskMemoryEvents = tryReadMemoryGraphEvents(1000); + } catch {} + const best = selectBestTask(hubTasks, taskMemoryEvents); + if (best) { + const alreadyClaimed = best.status === 'claimed'; + const claimed = alreadyClaimed || await claimTask(best.id || best.task_id); + if (claimed) { + activeTask = best; + const taskSignals = taskToSignals(best); + for (const sig of taskSignals) { + if (!signals.includes(sig)) signals.unshift(sig); + } + console.log(`[TaskReceiver] ${alreadyClaimed ? 'Resuming' : 'Claimed'} task: "${best.title || best.id}" (${taskSignals.length} signals injected)`); + } + } + } + } catch (e) { + console.log(`[TaskReceiver] Fetch/claim failed (non-fatal): ${e.message}`); + } + + const recentErrorMatches = recentMasterLog.match(/\[ERROR|Error:|Exception:|FAIL|Failed|"isError":true/gi) || []; + const recentErrorCount = recentErrorMatches.length; + + const evidence = { + // Keep short; do not store full transcripts in the graph. + recent_session_tail: String(recentMasterLog || '').slice(-6000), + today_log_tail: String(todayLog || '').slice(-2500), + }; + + const sessionScope = getSessionScope(); + const observations = { + agent: AGENT_NAME, + session_scope: sessionScope || null, + drift_enabled: IS_RANDOM_DRIFT, + review_mode: IS_REVIEW_MODE, + dry_run: IS_DRY_RUN, + system_health: healthReport, + mood: moodStatus, + scan_ms: scanTime, + memory_size_bytes: memorySize, + recent_error_count: recentErrorCount, + node: process.version, + platform: process.platform, + cwd: process.cwd(), + evidence, + }; + + if (sessionScope) { + console.log(`[SessionScope] Active scope: "${sessionScope}". Evolution state and memory graph are isolated.`); + } + + // Memory Graph: close last action with an inferred outcome (append-only graph, mutable state). + try { + recordOutcomeFromState({ signals, observations }); + } catch (e) { + // If we can't read/write memory graph, refuse to evolve (no "memoryless evolution"). + console.error(`[MemoryGraph] Outcome write failed: ${e.message}`); + console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); + throw new Error(`MemoryGraph Outcome write failed: ${e.message}`); + } + + // Memory Graph: record current signals as a first-class node. If this fails, refuse to evolve. + try { + recordSignalSnapshot({ signals, observations }); + } catch (e) { + console.error(`[MemoryGraph] Signal snapshot write failed: ${e.message}`); + console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); + throw new Error(`MemoryGraph Signal snapshot write failed: ${e.message}`); + } + + // Capability candidates (structured, short): persist and preview. + const newCandidates = extractCapabilityCandidates({ + recentSessionTranscript: recentMasterLog, + signals, + }); + for (const c of newCandidates) { + try { + appendCandidateJsonl(c); + } catch (e) {} + } + const recentCandidates = readRecentCandidates(20); + const capabilityCandidatesPreview = renderCandidatesPreview(recentCandidates.slice(-8), 1600); + + // External candidate zone (A2A receive): only surface candidates when local signals trigger them. + // External candidates are NEVER executed directly; they must be validated and promoted first. + let externalCandidatesPreview = '(none)'; + try { + const external = readRecentExternalCandidates(50); + const list = Array.isArray(external) ? external : []; + const capsulesOnly = list.filter(x => x && x.type === 'Capsule'); + const genesOnly = list.filter(x => x && x.type === 'Gene'); + + const matchedExternalGenes = genesOnly + .map(g => { + const pats = Array.isArray(g.signals_match) ? g.signals_match : []; + const hit = pats.reduce((acc, p) => (matchPatternToSignals(p, signals) ? acc + 1 : acc), 0); + return { gene: g, hit }; + }) + .filter(x => x.hit > 0) + .sort((a, b) => b.hit - a.hit) + .slice(0, 3) + .map(x => x.gene); + + const matchedExternalCapsules = capsulesOnly + .map(c => { + const triggers = Array.isArray(c.trigger) ? c.trigger : []; + const score = triggers.reduce((acc, t) => (matchPatternToSignals(t, signals) ? acc + 1 : acc), 0); + return { capsule: c, score }; + }) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map(x => x.capsule); + + if (matchedExternalGenes.length || matchedExternalCapsules.length) { + externalCandidatesPreview = `\`\`\`json\n${JSON.stringify( + [ + ...matchedExternalGenes.map(g => ({ + type: g.type, + id: g.id, + category: g.category || null, + signals_match: g.signals_match || [], + a2a: g.a2a || null, + })), + ...matchedExternalCapsules.map(c => ({ + type: c.type, + id: c.id, + trigger: c.trigger, + gene: c.gene, + summary: c.summary, + confidence: c.confidence, + blast_radius: c.blast_radius || null, + outcome: c.outcome || null, + success_streak: c.success_streak || null, + a2a: c.a2a || null, + })), + ], + null, + 2 + )}\n\`\`\``; + } + } catch (e) {} + + // Search-First Evolution: query Hub for reusable solutions before local reasoning. + let hubHit = null; + try { + hubHit = await hubSearch(signals, { timeoutMs: 8000 }); + if (hubHit && hubHit.hit) { + console.log(`[SearchFirst] Hub hit: asset=${hubHit.asset_id}, score=${hubHit.score}, mode=${hubHit.mode}`); + } else { + console.log(`[SearchFirst] No hub match (reason: ${hubHit && hubHit.reason ? hubHit.reason : 'unknown'}). Proceeding with local evolution.`); + } + } catch (e) { + console.log(`[SearchFirst] Hub search failed (non-fatal): ${e.message}`); + hubHit = { hit: false, reason: 'exception' }; + } + + // Memory Graph reasoning: prefer high-confidence paths, suppress known low-success paths (unless drift is explicit). + let memoryAdvice = null; + try { + memoryAdvice = getMemoryAdvice({ signals, genes, driftEnabled: IS_RANDOM_DRIFT }); + } catch (e) { + console.error(`[MemoryGraph] Read failed: ${e.message}`); + console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); + throw new Error(`MemoryGraph Read failed: ${e.message}`); + } + + var recentFailedCapsules = []; + try { + recentFailedCapsules = readRecentFailedCapsules(50); + } catch (e) { + console.log('[FailedCapsules] Read failed (non-fatal): ' + e.message); + } + + const { selectedGene, capsuleCandidates, selector } = selectGeneAndCapsule({ + genes, + capsules, + signals, + memoryAdvice, + driftEnabled: IS_RANDOM_DRIFT, + failedCapsules: recentFailedCapsules, + }); + + const selectedBy = memoryAdvice && memoryAdvice.preferredGeneId ? 'memory_graph+selector' : 'selector'; + const capsulesUsed = Array.isArray(capsuleCandidates) + ? capsuleCandidates.map(c => (c && c.id ? String(c.id) : null)).filter(Boolean) + : []; + const selectedCapsuleId = capsulesUsed.length ? capsulesUsed[0] : null; + + // Personality selection (natural selection + small mutation when triggered). + // This state is persisted in MEMORY_DIR and is treated as an evolution control surface (not role-play). + const personalitySelection = selectPersonalityForRun({ + driftEnabled: IS_RANDOM_DRIFT, + signals, + recentEvents, + }); + const personalityState = personalitySelection && personalitySelection.personality_state ? personalitySelection.personality_state : null; + + // Mutation object is mandatory for every evolution run. + const tail = Array.isArray(recentEvents) ? recentEvents.slice(-6) : []; + const tailOutcomes = tail + .map(e => (e && e.outcome && e.outcome.status ? String(e.outcome.status) : null)) + .filter(Boolean); + const stableSuccess = tailOutcomes.length >= 6 && tailOutcomes.every(s => s === 'success'); + const tailAvgScore = + tail.length > 0 + ? tail.reduce((acc, e) => acc + (e && e.outcome && Number.isFinite(Number(e.outcome.score)) ? Number(e.outcome.score) : 0), 0) / + tail.length + : 0; + const innovationPressure = + !IS_RANDOM_DRIFT && + personalityState && + Number.isFinite(Number(personalityState.creativity)) && + Number(personalityState.creativity) >= 0.75 && + stableSuccess && + tailAvgScore >= 0.7; + const forceInnovation = + String(process.env.FORCE_INNOVATION || process.env.EVOLVE_FORCE_INNOVATION || '').toLowerCase() === 'true'; + const mutationInnovateMode = !!IS_RANDOM_DRIFT || !!innovationPressure || !!forceInnovation; + const mutationSignals = innovationPressure ? [...(Array.isArray(signals) ? signals : []), 'stable_success_plateau'] : signals; + const mutationSignalsEffective = forceInnovation + ? [...(Array.isArray(mutationSignals) ? mutationSignals : []), 'force_innovation'] + : mutationSignals; + + const allowHighRisk = + !!IS_RANDOM_DRIFT && + !!personalitySelection && + !!personalitySelection.personality_known && + personalityState && + isHighRiskMutationAllowed(personalityState) && + Number(personalityState.rigor) >= 0.8 && + Number(personalityState.risk_tolerance) <= 0.3 && + !(Array.isArray(signals) && signals.includes('log_error')); + const mutation = buildMutation({ + signals: mutationSignalsEffective, + selectedGene, + driftEnabled: mutationInnovateMode, + personalityState, + allowHighRisk, + }); + + // Memory Graph: record hypothesis bridging Signal -> Action. If this fails, refuse to evolve. + let hypothesisId = null; + try { + const hyp = recordHypothesis({ + signals, + mutation, + personality_state: personalityState, + selectedGene, + selector, + driftEnabled: mutationInnovateMode, + selectedBy, + capsulesUsed, + observations, + }); + hypothesisId = hyp && hyp.hypothesisId ? hyp.hypothesisId : null; + } catch (e) { + console.error(`[MemoryGraph] Hypothesis write failed: ${e.message}`); + console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); + throw new Error(`MemoryGraph Hypothesis write failed: ${e.message}`); + } + + // Memory Graph: record the chosen causal path for this run. If this fails, refuse to output a mutation prompt. + try { + recordAttempt({ + signals, + mutation, + personality_state: personalityState, + selectedGene, + selector, + driftEnabled: mutationInnovateMode, + selectedBy, + hypothesisId, + capsulesUsed, + observations, + }); + } catch (e) { + console.error(`[MemoryGraph] Attempt write failed: ${e.message}`); + console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); + throw new Error(`MemoryGraph Attempt write failed: ${e.message}`); + } + + // Solidify state: capture minimal, auditable context for post-patch validation + asset write. + // This enforces strict protocol closure after patch application. + try { + const runId = `run_${Date.now()}`; + const parentEventId = getLastEventId(); + const selectedBy = memoryAdvice && memoryAdvice.preferredGeneId ? 'memory_graph+selector' : 'selector'; + + // Baseline snapshot (before any edits). + let baselineUntracked = []; + let baselineHead = null; + try { + const out = execSync('git ls-files --others --exclude-standard', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 4000, + windowsHide: true, + }); + baselineUntracked = String(out) + .split('\n') + .map(l => l.trim()) + .filter(Boolean); + } catch (e) {} + + try { + const out = execSync('git rev-parse HEAD', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 4000, + windowsHide: true, + }); + baselineHead = String(out || '').trim() || null; + } catch (e) {} + + const maxFiles = + selectedGene && selectedGene.constraints && Number.isFinite(Number(selectedGene.constraints.max_files)) + ? Number(selectedGene.constraints.max_files) + : 12; + const blastRadiusEstimate = { + files: Number.isFinite(maxFiles) && maxFiles > 0 ? maxFiles : 0, + lines: Number.isFinite(maxFiles) && maxFiles > 0 ? Math.round(maxFiles * 80) : 0, + }; + + // Merge into existing state to preserve last_solidify (do not wipe it). + const prevState = readStateForSolidify(); + prevState.last_run = { + run_id: runId, + created_at: new Date().toISOString(), + parent_event_id: parentEventId || null, + selected_gene_id: selectedGene && selectedGene.id ? selectedGene.id : null, + selected_capsule_id: selectedCapsuleId, + selector: selector || null, + signals: Array.isArray(signals) ? signals : [], + mutation: mutation || null, + mutation_id: mutation && mutation.id ? mutation.id : null, + personality_state: personalityState || null, + personality_key: personalitySelection && personalitySelection.personality_key ? personalitySelection.personality_key : null, + personality_known: !!(personalitySelection && personalitySelection.personality_known), + personality_mutations: + personalitySelection && Array.isArray(personalitySelection.personality_mutations) + ? personalitySelection.personality_mutations + : [], + drift: !!IS_RANDOM_DRIFT, + selected_by: selectedBy, + source_type: hubHit && hubHit.hit ? (hubHit.mode === 'direct' ? 'reused' : 'reference') : 'generated', + reused_asset_id: hubHit && hubHit.hit ? (hubHit.asset_id || null) : null, + reused_source_node: hubHit && hubHit.hit ? (hubHit.source_node_id || null) : null, + reused_chain_id: hubHit && hubHit.hit ? (hubHit.chain_id || null) : null, + baseline_untracked: baselineUntracked, + baseline_git_head: baselineHead, + blast_radius_estimate: blastRadiusEstimate, + active_task_id: activeTask ? (activeTask.id || activeTask.task_id || null) : null, + active_task_title: activeTask ? (activeTask.title || null) : null, + applied_lessons: hubLessons.map(function(l) { return l.lesson_id; }).filter(Boolean), + hub_lessons: hubLessons, + }; + writeStateForSolidify(prevState); + } catch (e) { + console.error(`[SolidifyState] Write failed: ${e.message}`); + } + + const genesPreview = `\`\`\`json\n${JSON.stringify(genes.slice(0, 6), null, 2)}\n\`\`\``; + const capsulesPreview = `\`\`\`json\n${JSON.stringify(capsules.slice(-3), null, 2)}\n\`\`\``; + + const reviewNote = IS_REVIEW_MODE + ? 'Review mode: before significant edits, pause and ask the user for confirmation.' + : 'Review mode: disabled.'; + + // Build recent evolution history summary for context injection + const recentHistorySummary = (() => { + if (!recentEvents || recentEvents.length === 0) return '(no prior evolution events)'; + const last8 = recentEvents.slice(-8); + const lines = last8.map((evt, idx) => { + const sigs = Array.isArray(evt.signals) ? evt.signals.slice(0, 3).join(', ') : '?'; + const gene = Array.isArray(evt.genes_used) && evt.genes_used.length ? evt.genes_used[0] : 'none'; + const outcome = evt.outcome && evt.outcome.status ? evt.outcome.status : '?'; + const ts = evt.meta && evt.meta.at ? evt.meta.at : (evt.id || ''); + return ` ${idx + 1}. [${evt.intent || '?'}] signals=[${sigs}] gene=${gene} outcome=${outcome} @${ts}`; + }); + return lines.join('\n'); + })(); + + const context = ` +Runtime state: +- System health: ${healthReport} +- Agent state: ${moodStatus} +- Scan duration: ${scanTime}ms +- Memory size: ${memorySize} bytes +- Skills available (if any): +${fileList || '[skills directory not found]'} + +Notes: +- ${reviewNote} +- ${reportingDirective} +- ${syncDirective} + +Recent Evolution History (last 8 cycles -- DO NOT repeat the same intent+signal+gene): +${recentHistorySummary} +IMPORTANT: If you see 3+ consecutive "repair" cycles with the same gene, you MUST switch to "innovate" intent. +${(() => { + // Compute consecutive failure count from recent events for context injection + let cfc = 0; + const evts = Array.isArray(recentEvents) ? recentEvents : []; + for (let i = evts.length - 1; i >= 0; i--) { + if (evts[i] && evts[i].outcome && evts[i].outcome.status === 'failed') cfc++; + else break; + } + if (cfc >= 3) { + return `\nFAILURE STREAK WARNING: The last ${cfc} cycles ALL FAILED. You MUST change your approach.\n- Do NOT repeat the same gene/strategy. Pick a completely different approach.\n- If the error is external (API down, binary missing), mark as FAILED and move on.\n- Prefer a minimal safe innovate cycle over yet another failing repair.`; + } + return ''; +})()} + +External candidates (A2A receive zone; staged only, never execute directly): +${externalCandidatesPreview} + +Global memory (MEMORY.md): +\`\`\` +${memorySnippet} +\`\`\` + +User registry (USER.md): +\`\`\` +${userSnippet} +\`\`\` + +Recent memory snippet: +\`\`\` +${todayLog.slice(-3000)} +\`\`\` + +Recent session transcript: +\`\`\` +${recentMasterLog} +\`\`\` + +Mutation directive: +${mutationDirective} +`.trim(); + + // Build the prompt: in direct-reuse mode, use a minimal reuse prompt. + // In reference mode (or no hit), use the full GEP prompt with hub match injected. + const isDirectReuse = hubHit && hubHit.hit && hubHit.mode === 'direct'; + const hubMatchedBlock = hubHit && hubHit.hit && hubHit.mode === 'reference' + ? buildHubMatchedBlock({ capsule: hubHit.match }) + : null; + + const prompt = isDirectReuse + ? buildReusePrompt({ + capsule: hubHit.match, + signals, + nowIso: new Date().toISOString(), + }) + : buildGepPrompt({ + nowIso: new Date().toISOString(), + context, + signals, + selector, + parentEventId: getLastEventId(), + selectedGene, + capsuleCandidates, + genesPreview, + capsulesPreview, + capabilityCandidatesPreview, + externalCandidatesPreview, + hubMatchedBlock, + failedCapsules: recentFailedCapsules, + hubLessons, + }); + + // Optional: emit a compact thought process block for wrappers (noise-controlled). + const emitThought = String(process.env.EVOLVE_EMIT_THOUGHT_PROCESS || '').toLowerCase() === 'true'; + if (emitThought) { + const s = Array.isArray(signals) ? signals : []; + const thought = [ + `cycle_id: ${cycleId}`, + `signals_count: ${s.length}`, + `signals: ${s.slice(0, 12).join(', ')}${s.length > 12 ? ' ...' : ''}`, + `selected_gene: ${selectedGene && selectedGene.id ? String(selectedGene.id) : '(none)'}`, + `selected_capsule: ${selectedCapsuleId ? String(selectedCapsuleId) : '(none)'}`, + `mutation_category: ${mutation && mutation.category ? String(mutation.category) : '(none)'}`, + `force_innovation: ${forceInnovation ? 'true' : 'false'}`, + `source_type: ${hubHit && hubHit.hit ? (isDirectReuse ? 'reused' : 'reference') : 'generated'}`, + `hub_reuse_mode: ${isDirectReuse ? 'direct' : hubMatchedBlock ? 'reference' : 'none'}`, + ].join('\n'); + console.log(`[THOUGHT_PROCESS]\n${thought}\n[/THOUGHT_PROCESS]`); + } + + const printPrompt = String(process.env.EVOLVE_PRINT_PROMPT || '').toLowerCase() === 'true'; + + // Default behavior (v1.4.1+): "execute-by-default" by bridging prompt -> sub-agent via sessions_spawn. + // This project is the Brain; the Hand is a spawned executor agent. Wrappers can disable bridging with EVOLVE_BRIDGE=false. + if (bridgeEnabled) { + // Reuse the run_id stored in the solidify state when possible. + let runId = `run_${Date.now()}`; + try { + const st = readStateForSolidify(); + if (st && st.last_run && st.last_run.run_id) runId = String(st.last_run.run_id); + } catch (e) {} + let artifact = null; + try { + artifact = writePromptArtifact({ + memoryDir: getEvolutionDir(), + cycleId, + runId, + prompt, + meta: { + agent: AGENT_NAME, + drift_enabled: IS_RANDOM_DRIFT, + review_mode: IS_REVIEW_MODE, + dry_run: IS_DRY_RUN, + mutation_id: mutation && mutation.id ? mutation.id : null, + personality_key: personalitySelection && personalitySelection.personality_key ? personalitySelection.personality_key : null, + }, + }); + } catch (e) { + artifact = null; + } + + const executorTask = [ + 'You are the executor (the Hand).', + 'Your job is to apply a safe, minimal patch in this repo following the attached GEP protocol prompt.', + artifact && artifact.promptPath ? `Prompt file: ${artifact.promptPath}` : 'Prompt file: (unavailable)', + '', + 'After applying changes and validations, you MUST run:', + ' node index.js solidify', + '', + 'Loop chaining (only if you are running in loop mode): after solidify succeeds, print a sessions_spawn call to start the next loop run with a short delay.', + 'Example:', + 'sessions_spawn({ task: "exec: node skills/feishu-evolver-wrapper/lifecycle.js ensure", agentId: "main", cleanup: "delete", label: "gep_loop_next" })', + '', + 'GEP protocol prompt (may be truncated here; prefer the prompt file if provided):', + clip(prompt, 24000), + ].join('\n'); + + const spawn = renderSessionsSpawnCall({ + task: executorTask, + agentId: AGENT_NAME, + cleanup: 'delete', + label: `gep_bridge_${cycleNum}`, + }); + + console.log('\n[BRIDGE ENABLED] Spawning executor agent via sessions_spawn.'); + console.log(spawn); + if (printPrompt) { + console.log('\n[PROMPT OUTPUT] (EVOLVE_PRINT_PROMPT=true)'); + console.log(prompt); + } + } else { + console.log(prompt); + console.log('\n[SOLIDIFY REQUIRED] After applying the patch and validations, run: node index.js solidify'); + } +} + +module.exports = { run }; + diff --git a/skills/capability-evolver/src/gep/a2a.js b/skills/capability-evolver/src/gep/a2a.js new file mode 100644 index 0000000..2e34008 --- /dev/null +++ b/skills/capability-evolver/src/gep/a2a.js @@ -0,0 +1,173 @@ +const fs = require('fs'); +const { readAllEvents } = require('./assetStore'); +const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); +const { unwrapAssetFromMessage } = require('./a2aProtocol'); + +function nowIso() { return new Date().toISOString(); } + +function isAllowedA2AAsset(obj) { + if (!obj || typeof obj !== 'object') return false; + var t = obj.type; + return t === 'Gene' || t === 'Capsule' || t === 'EvolutionEvent'; +} + +function safeNumber(x, fallback) { + if (fallback === undefined) fallback = null; + var n = Number(x); + return Number.isFinite(n) ? n : fallback; +} + +function getBlastRadiusLimits() { + var maxFiles = safeNumber(process.env.A2A_MAX_FILES, 5); + var maxLines = safeNumber(process.env.A2A_MAX_LINES, 200); + return { + maxFiles: Number.isFinite(maxFiles) ? maxFiles : 5, + maxLines: Number.isFinite(maxLines) ? maxLines : 200, + }; +} + +function isBlastRadiusSafe(blastRadius) { + var lim = getBlastRadiusLimits(); + var files = blastRadius && Number.isFinite(Number(blastRadius.files)) ? Number(blastRadius.files) : 0; + var lines = blastRadius && Number.isFinite(Number(blastRadius.lines)) ? Number(blastRadius.lines) : 0; + return files <= lim.maxFiles && lines <= lim.maxLines; +} + +function clamp01(n) { + var x = Number(n); + if (!Number.isFinite(x)) return 0; + return Math.max(0, Math.min(1, x)); +} + +function lowerConfidence(asset, opts) { + if (!opts) opts = {}; + var factor = Number.isFinite(Number(opts.factor)) ? Number(opts.factor) : 0.6; + var receivedFrom = opts.source || 'external'; + var receivedAt = opts.received_at || nowIso(); + var cloned = JSON.parse(JSON.stringify(asset || {})); + if (!isAllowedA2AAsset(cloned)) return null; + if (cloned.type === 'Capsule') { + if (typeof cloned.confidence === 'number') cloned.confidence = clamp01(cloned.confidence * factor); + else if (cloned.confidence != null) cloned.confidence = clamp01(Number(cloned.confidence) * factor); + } + if (!cloned.a2a || typeof cloned.a2a !== 'object') cloned.a2a = {}; + cloned.a2a.status = 'external_candidate'; + cloned.a2a.source = receivedFrom; + cloned.a2a.received_at = receivedAt; + cloned.a2a.confidence_factor = factor; + if (!cloned.schema_version) cloned.schema_version = SCHEMA_VERSION; + if (!cloned.asset_id) { try { cloned.asset_id = computeAssetId(cloned); } catch (e) {} } + return cloned; +} + +function readEvolutionEvents() { + var events = readAllEvents(); + return Array.isArray(events) ? events.filter(function (e) { return e && e.type === 'EvolutionEvent'; }) : []; +} + +function normalizeEventsList(events) { + return Array.isArray(events) ? events : []; +} + +function computeCapsuleSuccessStreak(params) { + var capsuleId = params.capsuleId; + var events = params.events; + var id = capsuleId ? String(capsuleId) : ''; + if (!id) return 0; + var list = normalizeEventsList(events || readEvolutionEvents()); + var streak = 0; + for (var i = list.length - 1; i >= 0; i--) { + var ev = list[i]; + if (!ev || ev.type !== 'EvolutionEvent') continue; + if (!ev.capsule_id || String(ev.capsule_id) !== id) continue; + var st = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown'; + if (st === 'success') streak += 1; else break; + } + return streak; +} + +function isCapsuleBroadcastEligible(capsule, opts) { + if (!opts) opts = {}; + if (!capsule || capsule.type !== 'Capsule') return false; + var score = capsule.outcome && capsule.outcome.score != null ? safeNumber(capsule.outcome.score, null) : null; + if (score == null || score < 0.7) return false; + var blast = capsule.blast_radius || (capsule.outcome && capsule.outcome.blast_radius) || null; + if (!isBlastRadiusSafe(blast)) return false; + var events = Array.isArray(opts.events) ? opts.events : readEvolutionEvents(); + var streak = computeCapsuleSuccessStreak({ capsuleId: capsule.id, events: events }); + if (streak < 2) return false; + return true; +} + +function exportEligibleCapsules(params) { + if (!params) params = {}; + var list = Array.isArray(params.capsules) ? params.capsules : []; + var evs = Array.isArray(params.events) ? params.events : readEvolutionEvents(); + var eligible = list.filter(function (c) { return isCapsuleBroadcastEligible(c, { events: evs }); }); + for (var i = 0; i < eligible.length; i++) { + var c = eligible[i]; + if (!c.schema_version) c.schema_version = SCHEMA_VERSION; + if (!c.asset_id) { try { c.asset_id = computeAssetId(c); } catch (e) {} } + } + return eligible; +} + +function isGeneBroadcastEligible(gene) { + if (!gene || gene.type !== 'Gene') return false; + if (!gene.id || typeof gene.id !== 'string') return false; + if (!Array.isArray(gene.strategy) || gene.strategy.length === 0) return false; + if (!Array.isArray(gene.validation) || gene.validation.length === 0) return false; + return true; +} + +function exportEligibleGenes(params) { + if (!params) params = {}; + var list = Array.isArray(params.genes) ? params.genes : []; + var eligible = list.filter(function (g) { return isGeneBroadcastEligible(g); }); + for (var i = 0; i < eligible.length; i++) { + var g = eligible[i]; + if (!g.schema_version) g.schema_version = SCHEMA_VERSION; + if (!g.asset_id) { try { g.asset_id = computeAssetId(g); } catch (e) {} } + } + return eligible; +} + +function parseA2AInput(text) { + var raw = String(text || '').trim(); + if (!raw) return []; + try { + var maybe = JSON.parse(raw); + if (Array.isArray(maybe)) { + return maybe.map(function (item) { return unwrapAssetFromMessage(item) || item; }).filter(Boolean); + } + if (maybe && typeof maybe === 'object') { + var unwrapped = unwrapAssetFromMessage(maybe); + return unwrapped ? [unwrapped] : [maybe]; + } + } catch (e) {} + var lines = raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean); + var items = []; + for (var i = 0; i < lines.length; i++) { + try { + var obj = JSON.parse(lines[i]); + var uw = unwrapAssetFromMessage(obj); + items.push(uw || obj); + } catch (e) { continue; } + } + return items; +} + +function readTextIfExists(filePath) { + try { + if (!filePath) return ''; + if (!fs.existsSync(filePath)) return ''; + return fs.readFileSync(filePath, 'utf8'); + } catch { return ''; } +} + +module.exports = { + isAllowedA2AAsset, lowerConfidence, isBlastRadiusSafe, + computeCapsuleSuccessStreak, isCapsuleBroadcastEligible, + exportEligibleCapsules, isGeneBroadcastEligible, + exportEligibleGenes, parseA2AInput, readTextIfExists, +}; diff --git a/skills/capability-evolver/src/gep/a2aProtocol.js b/skills/capability-evolver/src/gep/a2aProtocol.js new file mode 100644 index 0000000..d3695ff --- /dev/null +++ b/skills/capability-evolver/src/gep/a2aProtocol.js @@ -0,0 +1,560 @@ +// GEP A2A Protocol - Standard message types and pluggable transport layer. +// +// Protocol messages: +// hello - capability advertisement and node discovery +// publish - broadcast an eligible asset (Capsule/Gene) +// fetch - request a specific asset by id or content hash +// report - send a ValidationReport for a received asset +// decision - accept/reject/quarantine decision on a received asset +// revoke - withdraw a previously published asset +// +// Transport interface: +// send(message, opts) - send a protocol message +// receive(opts) - receive pending messages +// list(opts) - list available message files/streams +// +// Default transport: FileTransport (reads/writes JSONL to a2a/ directory). + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getGepAssetsDir } = require('./paths'); +const { computeAssetId } = require('./contentHash'); +const { captureEnvFingerprint } = require('./envFingerprint'); +const os = require('os'); +const { getDeviceId } = require('./deviceId'); + +const PROTOCOL_NAME = 'gep-a2a'; +const PROTOCOL_VERSION = '1.0.0'; +const VALID_MESSAGE_TYPES = ['hello', 'publish', 'fetch', 'report', 'decision', 'revoke']; + +const NODE_ID_RE = /^node_[a-f0-9]{12}$/; +const NODE_ID_DIR = path.join(os.homedir(), '.evomap'); +const NODE_ID_FILE = path.join(NODE_ID_DIR, 'node_id'); +const LOCAL_NODE_ID_FILE = path.resolve(__dirname, '..', '..', '.evomap_node_id'); + +let _cachedNodeId = null; + +function _loadPersistedNodeId() { + try { + if (fs.existsSync(NODE_ID_FILE)) { + const id = fs.readFileSync(NODE_ID_FILE, 'utf8').trim(); + if (id && NODE_ID_RE.test(id)) return id; + } + } catch {} + try { + if (fs.existsSync(LOCAL_NODE_ID_FILE)) { + const id = fs.readFileSync(LOCAL_NODE_ID_FILE, 'utf8').trim(); + if (id && NODE_ID_RE.test(id)) return id; + } + } catch {} + return null; +} + +function _persistNodeId(id) { + try { + if (!fs.existsSync(NODE_ID_DIR)) { + fs.mkdirSync(NODE_ID_DIR, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(NODE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 }); + return; + } catch {} + try { + fs.writeFileSync(LOCAL_NODE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 }); + return; + } catch {} +} + +function generateMessageId() { + return 'msg_' + Date.now() + '_' + crypto.randomBytes(4).toString('hex'); +} + +function getNodeId() { + if (_cachedNodeId) return _cachedNodeId; + + if (process.env.A2A_NODE_ID) { + _cachedNodeId = String(process.env.A2A_NODE_ID); + return _cachedNodeId; + } + + const persisted = _loadPersistedNodeId(); + if (persisted) { + _cachedNodeId = persisted; + return _cachedNodeId; + } + + const deviceId = getDeviceId(); + const agentName = process.env.AGENT_NAME || 'default'; + const raw = deviceId + '|' + agentName + '|' + process.cwd(); + const computed = 'node_' + crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12); + + _persistNodeId(computed); + _cachedNodeId = computed; + return _cachedNodeId; +} + +// --- Base message builder --- + +function buildMessage(params) { + var messageType = params.messageType; + var payload = params.payload; + var senderId = params.senderId; + if (!VALID_MESSAGE_TYPES.includes(messageType)) { + throw new Error('Invalid message type: ' + messageType + '. Valid: ' + VALID_MESSAGE_TYPES.join(', ')); + } + return { + protocol: PROTOCOL_NAME, + protocol_version: PROTOCOL_VERSION, + message_type: messageType, + message_id: generateMessageId(), + sender_id: senderId || getNodeId(), + timestamp: new Date().toISOString(), + payload: payload || {}, + }; +} + +// --- Typed message builders --- + +function buildHello(opts) { + var o = opts || {}; + return buildMessage({ + messageType: 'hello', + senderId: o.nodeId, + payload: { + capabilities: o.capabilities || {}, + gene_count: typeof o.geneCount === 'number' ? o.geneCount : null, + capsule_count: typeof o.capsuleCount === 'number' ? o.capsuleCount : null, + env_fingerprint: captureEnvFingerprint(), + }, + }); +} + +function buildPublish(opts) { + var o = opts || {}; + var asset = o.asset; + if (!asset || !asset.type || !asset.id) { + throw new Error('publish: asset must have type and id'); + } + // Generate signature: HMAC-SHA256 of asset_id with node secret + var assetIdVal = asset.asset_id || computeAssetId(asset); + var nodeSecret = process.env.A2A_NODE_SECRET || getNodeId(); + var signature = crypto.createHmac('sha256', nodeSecret).update(assetIdVal).digest('hex'); + return buildMessage({ + messageType: 'publish', + senderId: o.nodeId, + payload: { + asset_type: asset.type, + asset_id: assetIdVal, + local_id: asset.id, + asset: asset, + signature: signature, + }, + }); +} + +// Build a bundle publish message containing Gene + Capsule (+ optional EvolutionEvent). +// Hub requires payload.assets = [Gene, Capsule] since bundle enforcement was added. +function buildPublishBundle(opts) { + var o = opts || {}; + var gene = o.gene; + var capsule = o.capsule; + var event = o.event || null; + if (!gene || gene.type !== 'Gene' || !gene.id) { + throw new Error('publishBundle: gene must be a valid Gene with type and id'); + } + if (!capsule || capsule.type !== 'Capsule' || !capsule.id) { + throw new Error('publishBundle: capsule must be a valid Capsule with type and id'); + } + var geneAssetId = gene.asset_id || computeAssetId(gene); + var capsuleAssetId = capsule.asset_id || computeAssetId(capsule); + var nodeSecret = process.env.A2A_NODE_SECRET || getNodeId(); + var signatureInput = [geneAssetId, capsuleAssetId].sort().join('|'); + var signature = crypto.createHmac('sha256', nodeSecret).update(signatureInput).digest('hex'); + if (o.modelName && typeof o.modelName === 'string') { + gene.model_name = o.modelName; + capsule.model_name = o.modelName; + } + var assets = [gene, capsule]; + if (event && event.type === 'EvolutionEvent') { + if (o.modelName && typeof o.modelName === 'string') { + event.model_name = o.modelName; + } + assets.push(event); + } + var publishPayload = { + assets: assets, + signature: signature, + }; + if (o.chainId && typeof o.chainId === 'string') { + publishPayload.chain_id = o.chainId; + } + return buildMessage({ + messageType: 'publish', + senderId: o.nodeId, + payload: publishPayload, + }); +} + +function buildFetch(opts) { + var o = opts || {}; + return buildMessage({ + messageType: 'fetch', + senderId: o.nodeId, + payload: { + asset_type: o.assetType || null, + local_id: o.localId || null, + content_hash: o.contentHash || null, + }, + }); +} + +function buildReport(opts) { + var o = opts || {}; + return buildMessage({ + messageType: 'report', + senderId: o.nodeId, + payload: { + target_asset_id: o.assetId || null, + target_local_id: o.localId || null, + validation_report: o.validationReport || null, + }, + }); +} + +function buildDecision(opts) { + var o = opts || {}; + var validDecisions = ['accept', 'reject', 'quarantine']; + if (!validDecisions.includes(o.decision)) { + throw new Error('decision must be one of: ' + validDecisions.join(', ')); + } + return buildMessage({ + messageType: 'decision', + senderId: o.nodeId, + payload: { + target_asset_id: o.assetId || null, + target_local_id: o.localId || null, + decision: o.decision, + reason: o.reason || null, + }, + }); +} + +function buildRevoke(opts) { + var o = opts || {}; + return buildMessage({ + messageType: 'revoke', + senderId: o.nodeId, + payload: { + target_asset_id: o.assetId || null, + target_local_id: o.localId || null, + reason: o.reason || null, + }, + }); +} + +// --- Validation --- + +function isValidProtocolMessage(msg) { + if (!msg || typeof msg !== 'object') return false; + if (msg.protocol !== PROTOCOL_NAME) return false; + if (!msg.message_type || !VALID_MESSAGE_TYPES.includes(msg.message_type)) return false; + if (!msg.message_id || typeof msg.message_id !== 'string') return false; + if (!msg.timestamp || typeof msg.timestamp !== 'string') return false; + return true; +} + +// Try to extract a raw asset from either a protocol message or a plain asset object. +// This enables backward-compatible ingestion of both old-format and new-format payloads. +function unwrapAssetFromMessage(input) { + if (!input || typeof input !== 'object') return null; + // If it is a protocol message with a publish payload, extract the asset. + if (input.protocol === PROTOCOL_NAME && input.message_type === 'publish') { + var p = input.payload; + if (p && p.asset && typeof p.asset === 'object') return p.asset; + return null; + } + // If it is a plain asset (Gene/Capsule/EvolutionEvent), return as-is. + if (input.type === 'Gene' || input.type === 'Capsule' || input.type === 'EvolutionEvent') { + return input; + } + return null; +} + +// --- File Transport --- + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (e) {} +} + +function defaultA2ADir() { + return process.env.A2A_DIR || path.join(getGepAssetsDir(), 'a2a'); +} + +function fileTransportSend(message, opts) { + var dir = (opts && opts.dir) || defaultA2ADir(); + var subdir = path.join(dir, 'outbox'); + ensureDir(subdir); + var filePath = path.join(subdir, message.message_type + '.jsonl'); + fs.appendFileSync(filePath, JSON.stringify(message) + '\n', 'utf8'); + return { ok: true, path: filePath }; +} + +function fileTransportReceive(opts) { + var dir = (opts && opts.dir) || defaultA2ADir(); + var subdir = path.join(dir, 'inbox'); + if (!fs.existsSync(subdir)) return []; + var files = fs.readdirSync(subdir).filter(function (f) { return f.endsWith('.jsonl'); }); + var messages = []; + for (var fi = 0; fi < files.length; fi++) { + try { + var raw = fs.readFileSync(path.join(subdir, files[fi]), 'utf8'); + var lines = raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean); + for (var li = 0; li < lines.length; li++) { + try { + var msg = JSON.parse(lines[li]); + if (msg && msg.protocol === PROTOCOL_NAME) messages.push(msg); + } catch (e) {} + } + } catch (e) {} + } + return messages; +} + +function fileTransportList(opts) { + var dir = (opts && opts.dir) || defaultA2ADir(); + var subdir = path.join(dir, 'outbox'); + if (!fs.existsSync(subdir)) return []; + return fs.readdirSync(subdir).filter(function (f) { return f.endsWith('.jsonl'); }); +} + +// --- HTTP Transport (connects to evomap-hub) --- + +function httpTransportSend(message, opts) { + var hubUrl = (opts && opts.hubUrl) || process.env.A2A_HUB_URL; + if (!hubUrl) return { ok: false, error: 'A2A_HUB_URL not set' }; + var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/' + message.message_type; + var body = JSON.stringify(message); + // Use dynamic import for fetch (available in Node 18+) + return fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body, + }) + .then(function (res) { return res.json(); }) + .then(function (data) { return { ok: true, response: data }; }) + .catch(function (err) { return { ok: false, error: err.message }; }); +} + +function httpTransportReceive(opts) { + var hubUrl = (opts && opts.hubUrl) || process.env.A2A_HUB_URL; + if (!hubUrl) return Promise.resolve([]); + var assetType = (opts && opts.assetType) || null; + var fetchMsg = buildFetch({ assetType: assetType }); + var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/fetch'; + return fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fetchMsg), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data && data.payload && Array.isArray(data.payload.results)) { + return data.payload.results; + } + return []; + }) + .catch(function () { return []; }); +} + +function httpTransportList() { + return ['http']; +} + +// --- Heartbeat --- + +var _heartbeatTimer = null; +var _heartbeatStartedAt = null; +var _heartbeatConsecutiveFailures = 0; +var _heartbeatTotalSent = 0; +var _heartbeatTotalFailed = 0; + +function getHubUrl() { + return process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || ''; +} + +function sendHelloToHub() { + var hubUrl = getHubUrl(); + if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' }); + + var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/hello'; + var nodeId = getNodeId(); + var msg = buildHello({ nodeId: nodeId, capabilities: {} }); + msg.sender_id = nodeId; + + return fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(msg), + signal: AbortSignal.timeout(15000), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { return { ok: true, response: data }; }) + .catch(function (err) { return { ok: false, error: err.message }; }); +} + +function sendHeartbeat() { + var hubUrl = getHubUrl(); + if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' }); + + var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/heartbeat'; + var nodeId = getNodeId(); + var body = JSON.stringify({ + node_id: nodeId, + sender_id: nodeId, + version: PROTOCOL_VERSION, + uptime_ms: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0, + timestamp: new Date().toISOString(), + }); + + _heartbeatTotalSent++; + + return fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body, + signal: AbortSignal.timeout(10000), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data && data.status === 'unknown_node') { + console.warn('[Heartbeat] Node not registered on hub. Sending hello to re-register...'); + return sendHelloToHub().then(function (helloResult) { + if (helloResult.ok) { + console.log('[Heartbeat] Re-registered with hub successfully.'); + _heartbeatConsecutiveFailures = 0; + } else { + console.warn('[Heartbeat] Re-registration failed: ' + (helloResult.error || 'unknown')); + } + return { ok: helloResult.ok, response: data, reregistered: helloResult.ok }; + }); + } + _heartbeatConsecutiveFailures = 0; + return { ok: true, response: data }; + }) + .catch(function (err) { + _heartbeatConsecutiveFailures++; + _heartbeatTotalFailed++; + if (_heartbeatConsecutiveFailures === 3) { + console.warn('[Heartbeat] 3 consecutive failures. Network issue? Last error: ' + err.message); + } else if (_heartbeatConsecutiveFailures === 10) { + console.warn('[Heartbeat] 10 consecutive failures. Hub may be unreachable. (' + err.message + ')'); + } else if (_heartbeatConsecutiveFailures % 50 === 0) { + console.warn('[Heartbeat] ' + _heartbeatConsecutiveFailures + ' consecutive failures. (' + err.message + ')'); + } + return { ok: false, error: err.message }; + }); +} + +function startHeartbeat(intervalMs) { + if (_heartbeatTimer) return; + var interval = intervalMs || Number(process.env.HEARTBEAT_INTERVAL_MS) || 120000; // default 2min + _heartbeatStartedAt = Date.now(); + + // Register with hub first (hello), then start heartbeat loop + sendHelloToHub().then(function (r) { + if (r.ok) console.log('[Heartbeat] Registered with hub. Node: ' + getNodeId()); + else console.warn('[Heartbeat] Hello failed (will retry via heartbeat): ' + (r.error || 'unknown')); + }).catch(function () {}); + + // First heartbeat after a short delay (let hello complete first) + setTimeout(function () { + sendHeartbeat().then(function (r) { + if (r.ok) console.log('[Heartbeat] Connected to hub. Node: ' + getNodeId()); + }).catch(function () {}); + }, 5000); + + _heartbeatTimer = setInterval(function () { + sendHeartbeat().catch(function () {}); + }, interval); + + if (_heartbeatTimer.unref) _heartbeatTimer.unref(); +} + +function stopHeartbeat() { + if (_heartbeatTimer) { + clearInterval(_heartbeatTimer); + _heartbeatTimer = null; + } +} + +function getHeartbeatStats() { + return { + running: !!_heartbeatTimer, + uptimeMs: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0, + totalSent: _heartbeatTotalSent, + totalFailed: _heartbeatTotalFailed, + consecutiveFailures: _heartbeatConsecutiveFailures, + }; +} + +// --- Transport registry --- + +var transports = { + file: { + send: fileTransportSend, + receive: fileTransportReceive, + list: fileTransportList, + }, + http: { + send: httpTransportSend, + receive: httpTransportReceive, + list: httpTransportList, + }, +}; + +function getTransport(name) { + var n = String(name || process.env.A2A_TRANSPORT || 'file').toLowerCase(); + var t = transports[n]; + if (!t) throw new Error('Unknown A2A transport: ' + n + '. Available: ' + Object.keys(transports).join(', ')); + return t; +} + +function registerTransport(name, impl) { + if (!name || typeof name !== 'string') throw new Error('transport name required'); + if (!impl || typeof impl.send !== 'function' || typeof impl.receive !== 'function') { + throw new Error('transport must implement send() and receive()'); + } + transports[name] = impl; +} + +module.exports = { + PROTOCOL_NAME, + PROTOCOL_VERSION, + VALID_MESSAGE_TYPES, + getNodeId, + buildMessage, + buildHello, + buildPublish, + buildPublishBundle, + buildFetch, + buildReport, + buildDecision, + buildRevoke, + isValidProtocolMessage, + unwrapAssetFromMessage, + getTransport, + registerTransport, + fileTransportSend, + fileTransportReceive, + fileTransportList, + httpTransportSend, + httpTransportReceive, + httpTransportList, + sendHeartbeat, + sendHelloToHub, + startHeartbeat, + stopHeartbeat, + getHeartbeatStats, +}; diff --git a/skills/capability-evolver/src/gep/analyzer.js b/skills/capability-evolver/src/gep/analyzer.js new file mode 100644 index 0000000..7fd3f7a --- /dev/null +++ b/skills/capability-evolver/src/gep/analyzer.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); + +// Innovation: Self-Correction Analyzer +// Analyze past failures to suggest better future mutations +// Pattern: Meta-learning + +function analyzeFailures() { + const memoryPath = path.join(process.cwd(), 'MEMORY.md'); + if (!fs.existsSync(memoryPath)) return { status: 'skipped', reason: 'no_memory' }; + + const content = fs.readFileSync(memoryPath, 'utf8'); + const failureRegex = /\|\s*\*\*F\d+\*\*\s*\|\s*Fix\s*\|\s*(.*?)\s*\|\s*\*\*(.*?)\*\*\s*\((.*?)\)\s*\|/g; + + const failures = []; + let match; + while ((match = failureRegex.exec(content)) !== null) { + failures.push({ + summary: match[1].trim(), + detail: match[2].trim() + }); + } + + return { + status: 'success', + count: failures.length, + failures: failures.slice(0, 3) // Return top 3 for prompt context + }; +} + +if (require.main === module) { + console.log(JSON.stringify(analyzeFailures(), null, 2)); +} + +module.exports = { analyzeFailures }; diff --git a/skills/capability-evolver/src/gep/assetStore.js b/skills/capability-evolver/src/gep/assetStore.js new file mode 100644 index 0000000..144aba9 --- /dev/null +++ b/skills/capability-evolver/src/gep/assetStore.js @@ -0,0 +1,297 @@ +const fs = require('fs'); +const path = require('path'); +const { getGepAssetsDir } = require('./paths'); +const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function readJsonIfExists(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function writeJsonAtomic(filePath, obj) { + const dir = path.dirname(filePath); + ensureDir(dir); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, filePath); +} + +// Build a validation command using repo-root-relative paths. +// runValidations() executes with cwd=repoRoot, so require('./src/...') +// resolves correctly without embedding machine-specific absolute paths. +function buildValidationCmd(relModules) { + const paths = relModules.map(m => `./${m}`); + return `node scripts/validate-modules.js ${paths.join(' ')}`; +} + +function getDefaultGenes() { + return { + version: 1, + genes: [ + { + type: 'Gene', id: 'gene_gep_repair_from_errors', category: 'repair', + signals_match: ['error', 'exception', 'failed', 'unstable'], + preconditions: ['signals contains error-related indicators'], + strategy: [ + 'Extract structured signals from logs and user instructions', + 'Select an existing Gene by signals match (no improvisation)', + 'Estimate blast radius (files, lines) before editing', + 'Apply smallest reversible patch', + 'Validate using declared validation steps; rollback on failure', + 'Solidify knowledge: append EvolutionEvent, update Gene/Capsule store', + ], + constraints: { max_files: 12, forbidden_paths: ['.git', 'node_modules'] }, + validation: [ + buildValidationCmd(['src/evolve', 'src/gep/solidify']), + buildValidationCmd(['src/gep/selector', 'src/gep/memoryGraph']), + ], + }, + { + type: 'Gene', id: 'gene_gep_optimize_prompt_and_assets', category: 'optimize', + signals_match: ['protocol', 'gep', 'prompt', 'audit', 'reusable'], + preconditions: ['need stricter, auditable evolution protocol outputs'], + strategy: [ + 'Extract signals and determine selection rationale via Selector JSON', + 'Prefer reusing existing Gene/Capsule; only create if no match exists', + 'Refactor prompt assembly to embed assets (genes, capsules, parent event)', + 'Reduce noise and ambiguity; enforce strict output schema', + 'Validate by running node index.js run and ensuring no runtime errors', + 'Solidify: record EvolutionEvent, update Gene definitions, create Capsule on success', + ], + constraints: { max_files: 20, forbidden_paths: ['.git', 'node_modules'] }, + validation: [buildValidationCmd(['src/evolve', 'src/gep/prompt'])], + }, + ], + }; +} + +function getDefaultCapsules() { return { version: 1, capsules: [] }; } +function genesPath() { return path.join(getGepAssetsDir(), 'genes.json'); } +function capsulesPath() { return path.join(getGepAssetsDir(), 'capsules.json'); } +function capsulesJsonlPath() { return path.join(getGepAssetsDir(), 'capsules.jsonl'); } +function eventsPath() { return path.join(getGepAssetsDir(), 'events.jsonl'); } +function candidatesPath() { return path.join(getGepAssetsDir(), 'candidates.jsonl'); } +function externalCandidatesPath() { return path.join(getGepAssetsDir(), 'external_candidates.jsonl'); } +function failedCapsulesPath() { return path.join(getGepAssetsDir(), 'failed_capsules.json'); } + +function loadGenes() { + const jsonGenes = readJsonIfExists(genesPath(), getDefaultGenes()).genes || []; + const jsonlGenes = []; + try { + const p = path.join(getGepAssetsDir(), 'genes.jsonl'); + if (fs.existsSync(p)) { + const raw = fs.readFileSync(p, 'utf8'); + raw.split('\n').forEach(line => { + if (line.trim()) { + try { + const parsed = JSON.parse(line); + if (parsed && parsed.type === 'Gene') jsonlGenes.push(parsed); + } catch(e) {} + } + }); + } + } catch(e) {} + + // Combine and deduplicate by ID (JSONL takes precedence if newer, but here we just merge) + const combined = [...jsonGenes, ...jsonlGenes]; + const unique = new Map(); + combined.forEach(g => { + if (g && g.id) unique.set(String(g.id), g); + }); + return Array.from(unique.values()); +} + +function loadCapsules() { + const legacy = readJsonIfExists(capsulesPath(), getDefaultCapsules()).capsules || []; + const jsonlCapsules = []; + try { + const p = capsulesJsonlPath(); + if (fs.existsSync(p)) { + const raw = fs.readFileSync(p, 'utf8'); + raw.split('\n').forEach(line => { + if (line.trim()) { + try { jsonlCapsules.push(JSON.parse(line)); } catch(e) {} + } + }); + } + } catch(e) {} + + // Combine and deduplicate by ID + const combined = [...legacy, ...jsonlCapsules]; + const unique = new Map(); + combined.forEach(c => { + if (c && c.id) unique.set(String(c.id), c); + }); + return Array.from(unique.values()); +} + +function getLastEventId() { + try { + const p = eventsPath(); + if (!fs.existsSync(p)) return null; + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + if (lines.length === 0) return null; + const last = JSON.parse(lines[lines.length - 1]); + return last && typeof last.id === 'string' ? last.id : null; + } catch { return null; } +} + +function readAllEvents() { + try { + const p = eventsPath(); + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + return raw.split('\n').map(l => l.trim()).filter(Boolean).map(l => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + } catch { return []; } +} + +function appendEventJsonl(eventObj) { + const dir = getGepAssetsDir(); ensureDir(dir); + fs.appendFileSync(eventsPath(), JSON.stringify(eventObj) + '\n', 'utf8'); +} + +function appendCandidateJsonl(candidateObj) { + const dir = getGepAssetsDir(); ensureDir(dir); + fs.appendFileSync(candidatesPath(), JSON.stringify(candidateObj) + '\n', 'utf8'); +} + +function appendExternalCandidateJsonl(obj) { + const dir = getGepAssetsDir(); ensureDir(dir); + fs.appendFileSync(externalCandidatesPath(), JSON.stringify(obj) + '\n', 'utf8'); +} + +function readRecentCandidates(limit = 20) { + try { + const p = candidatesPath(); + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + return lines.slice(Math.max(0, lines.length - limit)).map(l => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + } catch { return []; } +} + +function readRecentExternalCandidates(limit = 50) { + try { + const p = externalCandidatesPath(); + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); + return lines.slice(Math.max(0, lines.length - limit)).map(l => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + } catch { return []; } +} + +// Safety net: ensure schema_version and asset_id are present before writing. +function ensureSchemaFields(obj) { + if (!obj || typeof obj !== 'object') return obj; + if (!obj.schema_version) obj.schema_version = SCHEMA_VERSION; + if (!obj.asset_id) { try { obj.asset_id = computeAssetId(obj); } catch (e) {} } + return obj; +} + +function upsertGene(geneObj) { + ensureSchemaFields(geneObj); + const current = readJsonIfExists(genesPath(), getDefaultGenes()); + const genes = Array.isArray(current.genes) ? current.genes : []; + const idx = genes.findIndex(g => g && g.id === geneObj.id); + if (idx >= 0) genes[idx] = geneObj; else genes.push(geneObj); + writeJsonAtomic(genesPath(), { version: current.version || 1, genes }); +} + +function appendCapsule(capsuleObj) { + ensureSchemaFields(capsuleObj); + const current = readJsonIfExists(capsulesPath(), getDefaultCapsules()); + const capsules = Array.isArray(current.capsules) ? current.capsules : []; + capsules.push(capsuleObj); + writeJsonAtomic(capsulesPath(), { version: current.version || 1, capsules }); +} + +function upsertCapsule(capsuleObj) { + if (!capsuleObj || capsuleObj.type !== 'Capsule' || !capsuleObj.id) return; + ensureSchemaFields(capsuleObj); + const current = readJsonIfExists(capsulesPath(), getDefaultCapsules()); + const capsules = Array.isArray(current.capsules) ? current.capsules : []; + const idx = capsules.findIndex(c => c && c.type === 'Capsule' && String(c.id) === String(capsuleObj.id)); + if (idx >= 0) capsules[idx] = capsuleObj; else capsules.push(capsuleObj); + writeJsonAtomic(capsulesPath(), { version: current.version || 1, capsules }); +} + +var FAILED_CAPSULES_MAX = 200; +var FAILED_CAPSULES_TRIM_TO = 100; + +function getDefaultFailedCapsules() { return { version: 1, failed_capsules: [] }; } + +function appendFailedCapsule(capsuleObj) { + if (!capsuleObj || typeof capsuleObj !== 'object') return; + ensureSchemaFields(capsuleObj); + var current = readJsonIfExists(failedCapsulesPath(), getDefaultFailedCapsules()); + var list = Array.isArray(current.failed_capsules) ? current.failed_capsules : []; + list.push(capsuleObj); + if (list.length > FAILED_CAPSULES_MAX) { + list = list.slice(list.length - FAILED_CAPSULES_TRIM_TO); + } + writeJsonAtomic(failedCapsulesPath(), { version: current.version || 1, failed_capsules: list }); +} + +function readRecentFailedCapsules(limit) { + var n = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Number(limit) : 50; + try { + var current = readJsonIfExists(failedCapsulesPath(), getDefaultFailedCapsules()); + var list = Array.isArray(current.failed_capsules) ? current.failed_capsules : []; + return list.slice(Math.max(0, list.length - n)); + } catch (e) { + return []; + } +} + +// Ensure all expected asset files exist on startup. +// Creates empty files for optional append-only stores so that +// external grep/read commands never fail with "No such file or directory". +function ensureAssetFiles() { + const dir = getGepAssetsDir(); + ensureDir(dir); + const files = [ + { path: genesPath(), defaultContent: JSON.stringify(getDefaultGenes(), null, 2) + '\n' }, + { path: capsulesPath(), defaultContent: JSON.stringify(getDefaultCapsules(), null, 2) + '\n' }, + { path: path.join(dir, 'genes.jsonl'), defaultContent: '' }, + { path: eventsPath(), defaultContent: '' }, + { path: candidatesPath(), defaultContent: '' }, + { path: failedCapsulesPath(), defaultContent: JSON.stringify(getDefaultFailedCapsules(), null, 2) + '\n' }, + ]; + for (const f of files) { + if (!fs.existsSync(f.path)) { + try { + fs.writeFileSync(f.path, f.defaultContent, 'utf8'); + } catch (e) { + // Non-fatal: log but continue + console.error(`[AssetStore] Failed to create ${f.path}: ${e.message}`); + } + } + } +} + +module.exports = { + loadGenes, loadCapsules, readAllEvents, getLastEventId, + appendEventJsonl, appendCandidateJsonl, appendExternalCandidateJsonl, + readRecentCandidates, readRecentExternalCandidates, + upsertGene, appendCapsule, upsertCapsule, + appendFailedCapsule, readRecentFailedCapsules, + genesPath, capsulesPath, eventsPath, candidatesPath, externalCandidatesPath, failedCapsulesPath, + ensureAssetFiles, buildValidationCmd, +}; diff --git a/skills/capability-evolver/src/gep/assets.js b/skills/capability-evolver/src/gep/assets.js new file mode 100644 index 0000000..333ce4c --- /dev/null +++ b/skills/capability-evolver/src/gep/assets.js @@ -0,0 +1,36 @@ +const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); + +/** + * Format asset preview for prompt inclusion. + * Handles stringified JSON, arrays, and error cases gracefully. + */ +function formatAssetPreview(preview) { + if (!preview) return '(none)'; + if (typeof preview === 'string') { + try { + const parsed = JSON.parse(preview); + if (Array.isArray(parsed) && parsed.length > 0) { + return JSON.stringify(parsed, null, 2); + } + return preview; // Keep as string if not array or empty + } catch (e) { + return preview; // Keep as string if parse fails + } + } + return JSON.stringify(preview, null, 2); +} + +/** + * Validate and normalize an asset object. + * Ensures schema version and ID are present. + */ +function normalizeAsset(asset) { + if (!asset || typeof asset !== 'object') return asset; + if (!asset.schema_version) asset.schema_version = SCHEMA_VERSION; + if (!asset.asset_id) { + try { asset.asset_id = computeAssetId(asset); } catch (e) {} + } + return asset; +} + +module.exports = { formatAssetPreview, normalizeAsset }; diff --git a/skills/capability-evolver/src/gep/bridge.js b/skills/capability-evolver/src/gep/bridge.js new file mode 100644 index 0000000..92a4db9 --- /dev/null +++ b/skills/capability-evolver/src/gep/bridge.js @@ -0,0 +1,71 @@ +const fs = require('fs'); +const path = require('path'); + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (e) {} +} + +function nowIso() { + return new Date().toISOString(); +} + +function clip(text, maxChars) { + const s = String(text || ''); + const n = Number(maxChars); + if (!Number.isFinite(n) || n <= 0) return s; + if (s.length <= n) return s; + return s.slice(0, Math.max(0, n - 40)) + '\n...[TRUNCATED]...\n'; +} + +function writePromptArtifact({ memoryDir, cycleId, runId, prompt, meta }) { + const dir = String(memoryDir || '').trim(); + if (!dir) throw new Error('bridge: missing memoryDir'); + ensureDir(dir); + const safeCycle = String(cycleId || 'cycle').replace(/[^a-zA-Z0-9_\-#]/g, '_'); + const safeRun = String(runId || Date.now()).replace(/[^a-zA-Z0-9_\-]/g, '_'); + const base = `gep_prompt_${safeCycle}_${safeRun}`; + const promptPath = path.join(dir, base + '.txt'); + const metaPath = path.join(dir, base + '.json'); + + fs.writeFileSync(promptPath, String(prompt || ''), 'utf8'); + fs.writeFileSync( + metaPath, + JSON.stringify( + { + type: 'GepPromptArtifact', + at: nowIso(), + cycle_id: cycleId || null, + run_id: runId || null, + prompt_path: promptPath, + meta: meta && typeof meta === 'object' ? meta : null, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + return { promptPath, metaPath }; +} + +function renderSessionsSpawnCall({ task, agentId, label, cleanup }) { + const t = String(task || '').trim(); + if (!t) throw new Error('bridge: missing task'); + const a = String(agentId || 'main'); + const l = String(label || 'gep_bridge'); + const c = cleanup ? String(cleanup) : 'delete'; + + // Output valid JSON so wrappers can parse with JSON.parse (not regex). + // The wrapper uses lastIndexOf('sessions_spawn(') + JSON.parse to extract the task. + const payload = JSON.stringify({ task: t, agentId: a, cleanup: c, label: l }); + return `sessions_spawn(${payload})`; +} + +module.exports = { + clip, + writePromptArtifact, + renderSessionsSpawnCall, +}; + diff --git a/skills/capability-evolver/src/gep/candidates.js b/skills/capability-evolver/src/gep/candidates.js new file mode 100644 index 0000000..d7a56f7 --- /dev/null +++ b/skills/capability-evolver/src/gep/candidates.js @@ -0,0 +1,142 @@ +function stableHash(input) { + // Deterministic lightweight hash (not cryptographic). + const s = String(input || ''); + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +function clip(text, maxChars) { + const s = String(text || ''); + if (!maxChars || s.length <= maxChars) return s; + return s.slice(0, Math.max(0, maxChars - 20)) + ' ...[TRUNCATED]'; +} + +function toLines(text) { + return String(text || '') + .split('\n') + .map(l => l.trimEnd()) + .filter(Boolean); +} + +function extractToolCalls(transcript) { + const lines = toLines(transcript); + const calls = []; + for (const line of lines) { + const m = line.match(/\[TOOL:\s*([^\]]+)\]/i); + if (m && m[1]) calls.push(m[1].trim()); + } + return calls; +} + +function countFreq(items) { + const map = new Map(); + for (const it of items) map.set(it, (map.get(it) || 0) + 1); + return map; +} + +function buildFiveQuestionsShape({ title, signals, evidence }) { + // Keep it short and structured; this is a template, not a perfect inference. + const input = 'Recent session transcript + memory snippets + user instructions'; + const output = 'A safe, auditable evolution patch guided by GEP assets'; + const invariants = 'Protocol order, small reversible patches, validation, append-only events'; + const params = `Signals: ${Array.isArray(signals) ? signals.join(', ') : ''}`.trim(); + const failurePoints = 'Missing signals, over-broad changes, skipped validation, missing knowledge solidification'; + return { + title: String(title || '').slice(0, 120), + input, + output, + invariants, + params: params || 'Signals: (none)', + failure_points: failurePoints, + evidence: clip(evidence, 240), + }; +} + +function extractCapabilityCandidates({ recentSessionTranscript, signals }) { + const candidates = []; + const toolCalls = extractToolCalls(recentSessionTranscript); + const freq = countFreq(toolCalls); + + for (const [tool, count] of freq.entries()) { + if (count < 2) continue; + const title = `Repeated tool usage: ${tool}`; + const evidence = `Observed ${count} occurrences of tool call marker for ${tool}.`; + const shape = buildFiveQuestionsShape({ title, signals, evidence }); + candidates.push({ + type: 'CapabilityCandidate', + id: `cand_${stableHash(title)}`, + title, + source: 'transcript', + created_at: new Date().toISOString(), + signals: Array.isArray(signals) ? signals : [], + shape, + }); + } + + // Signals-as-candidates: capture recurring pain points as reusable capability shapes. + const signalList = Array.isArray(signals) ? signals : []; + const signalCandidates = [ + // Defensive signals + { signal: 'log_error', title: 'Repair recurring runtime errors' }, + { signal: 'protocol_drift', title: 'Prevent protocol drift and enforce auditable outputs' }, + { signal: 'windows_shell_incompatible', title: 'Avoid platform-specific shell assumptions (Windows compatibility)' }, + { signal: 'session_logs_missing', title: 'Harden session log detection and fallback behavior' }, + // Opportunity signals (innovation) + { signal: 'user_feature_request', title: 'Implement user-requested feature' }, + { signal: 'user_improvement_suggestion', title: 'Apply user improvement suggestion' }, + { signal: 'perf_bottleneck', title: 'Resolve performance bottleneck' }, + { signal: 'capability_gap', title: 'Fill capability gap' }, + { signal: 'stable_success_plateau', title: 'Explore new strategies during stability plateau' }, + { signal: 'external_opportunity', title: 'Evaluate external A2A asset for local adoption' }, + ]; + + for (const sc of signalCandidates) { + if (!signalList.some(s => s === sc.signal || s.startsWith(sc.signal + ':'))) continue; + const evidence = `Signal present: ${sc.signal}`; + const shape = buildFiveQuestionsShape({ title: sc.title, signals, evidence }); + candidates.push({ + type: 'CapabilityCandidate', + id: `cand_${stableHash(sc.signal)}`, + title: sc.title, + source: 'signals', + created_at: new Date().toISOString(), + signals: signalList, + shape, + }); + } + + // Dedup by id + const seen = new Set(); + return candidates.filter(c => { + if (!c || !c.id) return false; + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }); +} + +function renderCandidatesPreview(candidates, maxChars = 1400) { + const list = Array.isArray(candidates) ? candidates : []; + const lines = []; + for (const c of list) { + const s = c && c.shape ? c.shape : {}; + lines.push(`- ${c.id}: ${c.title}`); + lines.push(` - input: ${s.input || ''}`); + lines.push(` - output: ${s.output || ''}`); + lines.push(` - invariants: ${s.invariants || ''}`); + lines.push(` - params: ${s.params || ''}`); + lines.push(` - failure_points: ${s.failure_points || ''}`); + if (s.evidence) lines.push(` - evidence: ${s.evidence}`); + } + return clip(lines.join('\n'), maxChars); +} + +module.exports = { + extractCapabilityCandidates, + renderCandidatesPreview, +}; + diff --git a/skills/capability-evolver/src/gep/contentHash.js b/skills/capability-evolver/src/gep/contentHash.js new file mode 100644 index 0000000..58c3f60 --- /dev/null +++ b/skills/capability-evolver/src/gep/contentHash.js @@ -0,0 +1,65 @@ +// Content-addressable hashing for GEP assets. +// Provides canonical JSON serialization and SHA-256 based asset IDs. +// This enables deduplication, tamper detection, and cross-node consistency. + +const crypto = require('crypto'); + +// Schema version for all GEP asset types. +// Bump MINOR for additive fields; MAJOR for breaking changes. +const SCHEMA_VERSION = '1.6.0'; + +// Canonical JSON: deterministic serialization with sorted keys at all levels. +// Arrays preserve order; non-finite numbers become null; undefined becomes null. +function canonicalize(obj) { + if (obj === null || obj === undefined) return 'null'; + if (typeof obj === 'boolean') return obj ? 'true' : 'false'; + if (typeof obj === 'number') { + if (!Number.isFinite(obj)) return 'null'; + return String(obj); + } + if (typeof obj === 'string') return JSON.stringify(obj); + if (Array.isArray(obj)) { + return '[' + obj.map(canonicalize).join(',') + ']'; + } + if (typeof obj === 'object') { + const keys = Object.keys(obj).sort(); + const pairs = []; + for (const k of keys) { + pairs.push(JSON.stringify(k) + ':' + canonicalize(obj[k])); + } + return '{' + pairs.join(',') + '}'; + } + return 'null'; +} + +// Compute a content-addressable asset ID. +// Excludes self-referential fields (asset_id itself) from the hash input. +// Returns "sha256:". +function computeAssetId(obj, excludeFields) { + if (!obj || typeof obj !== 'object') return null; + const exclude = new Set(Array.isArray(excludeFields) ? excludeFields : ['asset_id']); + const clean = {}; + for (const k of Object.keys(obj)) { + if (exclude.has(k)) continue; + clean[k] = obj[k]; + } + const canonical = canonicalize(clean); + const hash = crypto.createHash('sha256').update(canonical, 'utf8').digest('hex'); + return 'sha256:' + hash; +} + +// Verify that an object's asset_id matches its content. +function verifyAssetId(obj) { + if (!obj || typeof obj !== 'object') return false; + const claimed = obj.asset_id; + if (!claimed || typeof claimed !== 'string') return false; + const computed = computeAssetId(obj); + return claimed === computed; +} + +module.exports = { + SCHEMA_VERSION, + canonicalize, + computeAssetId, + verifyAssetId, +}; diff --git a/skills/capability-evolver/src/gep/deviceId.js b/skills/capability-evolver/src/gep/deviceId.js new file mode 100644 index 0000000..3f67038 --- /dev/null +++ b/skills/capability-evolver/src/gep/deviceId.js @@ -0,0 +1,209 @@ +// Stable device identifier for node identity. +// Generates a hardware-based fingerprint that persists across directory changes, +// reboots, and evolver upgrades. Used by getNodeId() and env_fingerprint. +// +// Priority chain: +// 1. EVOMAP_DEVICE_ID env var (explicit override, recommended for containers) +// 2. ~/.evomap/device_id file (persisted from previous run) +// 3. /.evomap_device_id (fallback persist path for containers w/o $HOME) +// 4. /etc/machine-id (Linux, set at OS install) +// 5. IOPlatformUUID (macOS hardware UUID) +// 6. Docker/OCI container ID (from /proc/self/cgroup or /proc/self/mountinfo) +// 7. hostname + MAC addresses (network-based fallback) +// 8. random 128-bit hex (last resort, persisted immediately) + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const DEVICE_ID_DIR = path.join(os.homedir(), '.evomap'); +const DEVICE_ID_FILE = path.join(DEVICE_ID_DIR, 'device_id'); +const LOCAL_DEVICE_ID_FILE = path.resolve(__dirname, '..', '..', '.evomap_device_id'); + +let _cachedDeviceId = null; + +const DEVICE_ID_RE = /^[a-f0-9]{16,64}$/; + +function isContainer() { + try { + if (fs.existsSync('/.dockerenv')) return true; + } catch {} + try { + const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8'); + if (/docker|kubepods|containerd|cri-o|lxc|ecs/i.test(cgroup)) return true; + } catch {} + try { + if (fs.existsSync('/run/.containerenv')) return true; + } catch {} + return false; +} + +function readMachineId() { + try { + const mid = fs.readFileSync('/etc/machine-id', 'utf8').trim(); + if (mid && mid.length >= 16) return mid; + } catch {} + + if (process.platform === 'darwin') { + try { + const { execFileSync } = require('child_process'); + const raw = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { + encoding: 'utf8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = raw.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + if (match && match[1]) return match[1]; + } catch {} + } + + return null; +} + +// Extract Docker/OCI container ID from cgroup or mountinfo. +// The container ID is 64-char hex and stable for the lifetime of the container. +// Returns null on non-container hosts or if parsing fails. +function readContainerId() { + // Method 1: /proc/self/cgroup (works for cgroup v1 and most Docker setups) + try { + const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8'); + const match = cgroup.match(/[a-f0-9]{64}/); + if (match) return match[0]; + } catch {} + + // Method 2: /proc/self/mountinfo (works for cgroup v2 / containerd) + try { + const mountinfo = fs.readFileSync('/proc/self/mountinfo', 'utf8'); + const match = mountinfo.match(/[a-f0-9]{64}/); + if (match) return match[0]; + } catch {} + + // Method 3: hostname in Docker defaults to short container ID (12 hex chars) + if (isContainer()) { + const hostname = os.hostname(); + if (/^[a-f0-9]{12,64}$/.test(hostname)) return hostname; + } + + return null; +} + +function getMacAddresses() { + const ifaces = os.networkInterfaces(); + const macs = []; + for (const name of Object.keys(ifaces)) { + for (const iface of ifaces[name]) { + if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { + macs.push(iface.mac); + } + } + } + macs.sort(); + return macs; +} + +function generateDeviceId() { + const machineId = readMachineId(); + if (machineId) { + return crypto.createHash('sha256').update('evomap:' + machineId).digest('hex').slice(0, 32); + } + + // Container ID: stable for the container's lifetime, but changes on re-create. + // Still better than random for keeping identity within a single deployment. + const containerId = readContainerId(); + if (containerId) { + return crypto.createHash('sha256').update('evomap:container:' + containerId).digest('hex').slice(0, 32); + } + + const macs = getMacAddresses(); + if (macs.length > 0) { + const raw = os.hostname() + '|' + macs.join(','); + return crypto.createHash('sha256').update('evomap:' + raw).digest('hex').slice(0, 32); + } + + return crypto.randomBytes(16).toString('hex'); +} + +function persistDeviceId(id) { + // Try primary path (~/.evomap/device_id) + try { + if (!fs.existsSync(DEVICE_ID_DIR)) { + fs.mkdirSync(DEVICE_ID_DIR, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 }); + return; + } catch {} + + // Fallback: project-local file (useful in containers where $HOME is ephemeral + // but the project directory is mounted as a volume) + try { + fs.writeFileSync(LOCAL_DEVICE_ID_FILE, id, { encoding: 'utf8', mode: 0o600 }); + return; + } catch {} + + console.error( + '[evolver] WARN: failed to persist device_id to ' + DEVICE_ID_FILE + + ' or ' + LOCAL_DEVICE_ID_FILE + + ' -- node identity may change on restart.' + + ' Set EVOMAP_DEVICE_ID env var for stable identity in containers.' + ); +} + +function loadPersistedDeviceId() { + // Try primary path + try { + if (fs.existsSync(DEVICE_ID_FILE)) { + const id = fs.readFileSync(DEVICE_ID_FILE, 'utf8').trim(); + if (id && DEVICE_ID_RE.test(id)) return id; + } + } catch {} + + // Try project-local fallback + try { + if (fs.existsSync(LOCAL_DEVICE_ID_FILE)) { + const id = fs.readFileSync(LOCAL_DEVICE_ID_FILE, 'utf8').trim(); + if (id && DEVICE_ID_RE.test(id)) return id; + } + } catch {} + + return null; +} + +function getDeviceId() { + if (_cachedDeviceId) return _cachedDeviceId; + + // 1. Env var override (validated) + if (process.env.EVOMAP_DEVICE_ID) { + const envId = String(process.env.EVOMAP_DEVICE_ID).trim().toLowerCase(); + if (DEVICE_ID_RE.test(envId)) { + _cachedDeviceId = envId; + return _cachedDeviceId; + } + } + + // 2. Previously persisted (checks both ~/.evomap/ and project-local) + const persisted = loadPersistedDeviceId(); + if (persisted) { + _cachedDeviceId = persisted; + return _cachedDeviceId; + } + + // 3. Generate from hardware / container metadata and persist + const inContainer = isContainer(); + const generated = generateDeviceId(); + persistDeviceId(generated); + _cachedDeviceId = generated; + + if (inContainer && !process.env.EVOMAP_DEVICE_ID) { + console.error( + '[evolver] NOTE: running in a container without EVOMAP_DEVICE_ID.' + + ' A device_id was auto-generated and persisted, but for guaranteed' + + ' cross-restart stability, set EVOMAP_DEVICE_ID as an env var' + + ' or mount a persistent volume at ~/.evomap/' + ); + } + + return _cachedDeviceId; +} + +module.exports = { getDeviceId, isContainer }; diff --git a/skills/capability-evolver/src/gep/envFingerprint.js b/skills/capability-evolver/src/gep/envFingerprint.js new file mode 100644 index 0000000..1ac2523 --- /dev/null +++ b/skills/capability-evolver/src/gep/envFingerprint.js @@ -0,0 +1,68 @@ +// Environment fingerprint capture for GEP assets. +// Records the runtime environment so that cross-environment diffusion +// success rates (GDI) can be measured scientifically. + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getRepoRoot } = require('./paths'); +const { getDeviceId, isContainer } = require('./deviceId'); + +// Capture a structured environment fingerprint. +// This is embedded into Capsules, EvolutionEvents, and ValidationReports. +function captureEnvFingerprint() { + const repoRoot = getRepoRoot(); + let pkgVersion = null; + let pkgName = null; + try { + const raw = fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); + pkgVersion = pkg && pkg.version ? String(pkg.version) : null; + pkgName = pkg && pkg.name ? String(pkg.name) : null; + } catch (e) {} + + const region = (process.env.EVOLVER_REGION || '').trim().toLowerCase().slice(0, 5) || undefined; + + return { + device_id: getDeviceId(), + node_version: process.version, + platform: process.platform, + arch: process.arch, + os_release: os.release(), + hostname: crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 12), + evolver_version: pkgVersion, + client: pkgName || 'evolver', + client_version: pkgVersion, + region: region, + cwd: crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12), + container: isContainer(), + }; +} + +// Compute a short fingerprint key for comparison and grouping. +// Two nodes with the same key are considered "same environment class". +function envFingerprintKey(fp) { + if (!fp || typeof fp !== 'object') return 'unknown'; + const parts = [ + fp.device_id || '', + fp.node_version || '', + fp.platform || '', + fp.arch || '', + fp.hostname || '', + fp.client || fp.evolver_version || '', + fp.client_version || fp.evolver_version || '', + ].join('|'); + return crypto.createHash('sha256').update(parts, 'utf8').digest('hex').slice(0, 16); +} + +// Check if two fingerprints are from the same environment class. +function isSameEnvClass(fpA, fpB) { + return envFingerprintKey(fpA) === envFingerprintKey(fpB); +} + +module.exports = { + captureEnvFingerprint, + envFingerprintKey, + isSameEnvClass, +}; diff --git a/skills/capability-evolver/src/gep/hubSearch.js b/skills/capability-evolver/src/gep/hubSearch.js new file mode 100644 index 0000000..c70e749 --- /dev/null +++ b/skills/capability-evolver/src/gep/hubSearch.js @@ -0,0 +1,133 @@ +// Hub Search-First Evolution: query evomap-hub for reusable solutions before local solve. +// +// Flow: extractSignals() -> hubSearch(signals) -> if hit: reuse; if miss: normal evolve +// Two modes: direct (skip local reasoning) | reference (inject into prompt as strong hint) + +const { getNodeId } = require('./a2aProtocol'); + +const DEFAULT_MIN_REUSE_SCORE = 0.72; +const DEFAULT_REUSE_MODE = 'reference'; // 'direct' | 'reference' + +function getHubUrl() { + return (process.env.A2A_HUB_URL || '').replace(/\/+$/, ''); +} + +function getReuseMode() { + const m = String(process.env.EVOLVER_REUSE_MODE || DEFAULT_REUSE_MODE).toLowerCase(); + return m === 'direct' ? 'direct' : 'reference'; +} + +function getMinReuseScore() { + const n = Number(process.env.EVOLVER_MIN_REUSE_SCORE); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_REUSE_SCORE; +} + +/** + * Score a hub asset for local reuse quality. + * rank = confidence * max(success_streak, 1) * (reputation / 100) + */ +function scoreHubResult(asset) { + const confidence = Number(asset.confidence) || 0; + const streak = Math.max(Number(asset.success_streak) || 0, 1); + // Reputation is included in asset from hub ranked endpoint; default 50 if missing + const reputation = Number(asset.reputation_score) || 50; + return confidence * streak * (reputation / 100); +} + +/** + * Pick the best matching asset above the threshold. + * Returns { match, score, mode } or null if nothing qualifies. + */ +function pickBestMatch(results, threshold) { + if (!Array.isArray(results) || results.length === 0) return null; + + let best = null; + let bestScore = 0; + + for (const asset of results) { + // Only consider promoted assets + if (asset.status && asset.status !== 'promoted') continue; + const s = scoreHubResult(asset); + if (s > bestScore) { + bestScore = s; + best = asset; + } + } + + if (!best || bestScore < threshold) return null; + + return { + match: best, + score: Math.round(bestScore * 1000) / 1000, + mode: getReuseMode(), + }; +} + +/** + * Search the hub for reusable capsules matching the given signals. + * Returns { hit: true, match, score, mode } or { hit: false }. + */ +async function hubSearch(signals, opts) { + const hubUrl = getHubUrl(); + if (!hubUrl) return { hit: false, reason: 'no_hub_url' }; + + const signalList = Array.isArray(signals) ? signals.filter(Boolean) : []; + if (signalList.length === 0) return { hit: false, reason: 'no_signals' }; + + const threshold = (opts && Number.isFinite(opts.threshold)) ? opts.threshold : getMinReuseScore(); + const limit = (opts && Number.isFinite(opts.limit)) ? opts.limit : 5; + const timeout = (opts && Number.isFinite(opts.timeoutMs)) ? opts.timeoutMs : 8000; + + try { + const params = new URLSearchParams(); + params.set('signals', signalList.join(',')); + params.set('status', 'promoted'); + params.set('limit', String(limit)); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + const url = `${hubUrl}/a2a/assets/search?${params.toString()}`; + const res = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) return { hit: false, reason: `hub_http_${res.status}` }; + + const data = await res.json(); + const assets = Array.isArray(data.assets) ? data.assets : []; + + if (assets.length === 0) return { hit: false, reason: 'no_results' }; + + const pick = pickBestMatch(assets, threshold); + if (!pick) return { hit: false, reason: 'below_threshold', candidates: assets.length }; + + console.log(`[HubSearch] Hit: ${pick.match.asset_id || pick.match.local_id} (score=${pick.score}, mode=${pick.mode})`); + + return { + hit: true, + match: pick.match, + score: pick.score, + mode: pick.mode, + asset_id: pick.match.asset_id || null, + source_node_id: pick.match.source_node_id || null, + chain_id: pick.match.chain_id || null, + }; + } catch (err) { + // Hub unreachable is non-fatal; fall through to normal evolve + console.log(`[HubSearch] Failed (non-fatal): ${err.message}`); + return { hit: false, reason: 'fetch_error', error: err.message }; + } +} + +module.exports = { + hubSearch, + scoreHubResult, + pickBestMatch, + getReuseMode, + getMinReuseScore, + getHubUrl, +}; diff --git a/skills/capability-evolver/src/gep/memoryGraph.js b/skills/capability-evolver/src/gep/memoryGraph.js new file mode 100644 index 0000000..e22d9d9 --- /dev/null +++ b/skills/capability-evolver/src/gep/memoryGraph.js @@ -0,0 +1,771 @@ +const fs = require('fs'); +const path = require('path'); +const { getMemoryDir } = require('./paths'); +const { normalizePersonalityState, isValidPersonalityState, personalityKey } = require('./personality'); +const { isValidMutation, normalizeMutation } = require('./mutation'); + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (e) {} +} + +function stableHash(input) { + const s = String(input || ''); + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +function nowIso() { + return new Date().toISOString(); +} + +function normalizeErrorSignature(text) { + const s = String(text || '').trim(); + if (!s) return null; + return ( + s + .toLowerCase() + // normalize Windows paths + .replace(/[a-z]:\\[^ \n\r\t]+/gi, '') + // normalize Unix paths + .replace(/\/[^ \n\r\t]+/g, '') + // normalize hex and numbers + .replace(/\b0x[0-9a-f]+\b/gi, '') + .replace(/\b\d+\b/g, '') + // normalize whitespace + .replace(/\s+/g, ' ') + .slice(0, 220) + ); +} + +function normalizeSignalsForMatching(signals) { + const list = Array.isArray(signals) ? signals : []; + const out = []; + for (const s of list) { + const str = String(s || '').trim(); + if (!str) continue; + if (str.startsWith('errsig:')) { + const norm = normalizeErrorSignature(str.slice('errsig:'.length)); + if (norm) out.push(`errsig_norm:${stableHash(norm)}`); + continue; + } + out.push(str); + } + return out; +} + +function computeSignalKey(signals) { + // Key must be stable across runs; normalize noisy signatures (paths, numbers). + const list = normalizeSignalsForMatching(signals); + const uniq = Array.from(new Set(list.filter(Boolean))).sort(); + return uniq.join('|') || '(none)'; +} + +function extractErrorSignatureFromSignals(signals) { + // Convention: signals can include "errsig:" emitted by signals extractor. + const list = Array.isArray(signals) ? signals : []; + for (const s of list) { + const str = String(s || ''); + if (str.startsWith('errsig:')) return normalizeErrorSignature(str.slice('errsig:'.length)); + } + return null; +} + +function memoryGraphPath() { + const { getEvolutionDir } = require('./paths'); + const evoDir = getEvolutionDir(); + return process.env.MEMORY_GRAPH_PATH || path.join(evoDir, 'memory_graph.jsonl'); +} + +function memoryGraphStatePath() { + const { getEvolutionDir } = require('./paths'); + return path.join(getEvolutionDir(), 'memory_graph_state.json'); +} + +function appendJsonl(filePath, obj) { + const dir = path.dirname(filePath); + ensureDir(dir); + fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf8'); +} + +function readJsonIfExists(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch (e) { + return fallback; + } +} + +function writeJsonAtomic(filePath, obj) { + const dir = path.dirname(filePath); + ensureDir(dir); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, filePath); +} + +function tryReadMemoryGraphEvents(limitLines = 2000) { + try { + const p = memoryGraphPath(); + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + const lines = raw + .split('\n') + .map(l => l.trim()) + .filter(Boolean); + const recent = lines.slice(Math.max(0, lines.length - limitLines)); + return recent + .map(l => { + try { + return JSON.parse(l); + } catch (e) { + return null; + } + }) + .filter(Boolean); + } catch (e) { + return []; + } +} + +function jaccard(aList, bList) { + const aNorm = normalizeSignalsForMatching(aList); + const bNorm = normalizeSignalsForMatching(bList); + const a = new Set((Array.isArray(aNorm) ? aNorm : []).map(String)); + const b = new Set((Array.isArray(bNorm) ? bNorm : []).map(String)); + if (a.size === 0 && b.size === 0) return 1; + if (a.size === 0 || b.size === 0) return 0; + let inter = 0; + for (const x of a) if (b.has(x)) inter++; + const union = a.size + b.size - inter; + return union === 0 ? 0 : inter / union; +} + +function decayWeight(updatedAtIso, halfLifeDays) { + const hl = Number(halfLifeDays); + if (!Number.isFinite(hl) || hl <= 0) return 1; + const t = Date.parse(updatedAtIso); + if (!Number.isFinite(t)) return 1; + const ageDays = (Date.now() - t) / (1000 * 60 * 60 * 24); + if (!Number.isFinite(ageDays) || ageDays <= 0) return 1; + // Exponential half-life decay: weight = 0.5^(age/hl) + return Math.pow(0.5, ageDays / hl); +} + +function aggregateEdges(events) { + // Aggregate by (signal_key, gene_id) from outcome events. + // Laplace smoothing to avoid 0/1 extremes. + const map = new Map(); + for (const ev of events) { + if (!ev || ev.type !== 'MemoryGraphEvent') continue; + if (ev.kind !== 'outcome') continue; + const signalKey = ev.signal && ev.signal.key ? String(ev.signal.key) : '(none)'; + const geneId = ev.gene && ev.gene.id ? String(ev.gene.id) : null; + if (!geneId) continue; + + const k = `${signalKey}::${geneId}`; + const cur = map.get(k) || { signalKey, geneId, success: 0, fail: 0, last_ts: null, last_score: null }; + const status = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown'; + if (status === 'success') cur.success += 1; + else if (status === 'failed') cur.fail += 1; + + const ts = ev.ts || ev.created_at || ev.at; + if (ts && (!cur.last_ts || Date.parse(ts) > Date.parse(cur.last_ts))) { + cur.last_ts = ts; + cur.last_score = + ev.outcome && Number.isFinite(Number(ev.outcome.score)) ? Number(ev.outcome.score) : cur.last_score; + } + map.set(k, cur); + } + return map; +} + +function aggregateGeneOutcomes(events) { + // Aggregate by gene_id from outcome events (gene -> outcome success probability). + const map = new Map(); + for (const ev of events) { + if (!ev || ev.type !== 'MemoryGraphEvent') continue; + if (ev.kind !== 'outcome') continue; + const geneId = ev.gene && ev.gene.id ? String(ev.gene.id) : null; + if (!geneId) continue; + const cur = map.get(geneId) || { geneId, success: 0, fail: 0, last_ts: null, last_score: null }; + const status = ev.outcome && ev.outcome.status ? String(ev.outcome.status) : 'unknown'; + if (status === 'success') cur.success += 1; + else if (status === 'failed') cur.fail += 1; + const ts = ev.ts || ev.created_at || ev.at; + if (ts && (!cur.last_ts || Date.parse(ts) > Date.parse(cur.last_ts))) { + cur.last_ts = ts; + cur.last_score = + ev.outcome && Number.isFinite(Number(ev.outcome.score)) ? Number(ev.outcome.score) : cur.last_score; + } + map.set(geneId, cur); + } + return map; +} + +function edgeExpectedSuccess(edge, opts) { + const e = edge || { success: 0, fail: 0, last_ts: null }; + const succ = Number(e.success) || 0; + const fail = Number(e.fail) || 0; + const total = succ + fail; + const p = (succ + 1) / (total + 2); // Laplace smoothing + const halfLifeDays = opts && Number.isFinite(Number(opts.half_life_days)) ? Number(opts.half_life_days) : 30; + const w = decayWeight(e.last_ts || '', halfLifeDays); + return { p, w, total, value: p * w }; +} + +function getMemoryAdvice({ signals, genes, driftEnabled }) { + const events = tryReadMemoryGraphEvents(2000); + const edges = aggregateEdges(events); + const geneOutcomes = aggregateGeneOutcomes(events); + const curSignals = Array.isArray(signals) ? signals : []; + const curKey = computeSignalKey(curSignals); + + const bannedGeneIds = new Set(); + const scoredGeneIds = []; + + // Similarity: consider exact key first, then any key with overlap. + const seenKeys = new Set(); + const candidateKeys = []; + candidateKeys.push({ key: curKey, sim: 1 }); + seenKeys.add(curKey); + + for (const ev of events) { + if (!ev || ev.type !== 'MemoryGraphEvent') continue; + const k = ev.signal && ev.signal.key ? String(ev.signal.key) : '(none)'; + if (seenKeys.has(k)) continue; + const sigs = ev.signal && Array.isArray(ev.signal.signals) ? ev.signal.signals : []; + const sim = jaccard(curSignals, sigs); + if (sim >= 0.34) { + candidateKeys.push({ key: k, sim }); + seenKeys.add(k); + } + } + + const byGene = new Map(); + for (const ck of candidateKeys) { + for (const g of Array.isArray(genes) ? genes : []) { + if (!g || g.type !== 'Gene' || !g.id) continue; + const k = `${ck.key}::${g.id}`; + const edge = edges.get(k); + const cur = byGene.get(g.id) || { geneId: g.id, best: 0, attempts: 0, prior: 0, prior_attempts: 0 }; + + // Signal->Gene edge score (if available) + if (edge) { + const ex = edgeExpectedSuccess(edge, { half_life_days: 30 }); + const weighted = ex.value * ck.sim; + if (weighted > cur.best) cur.best = weighted; + cur.attempts = Math.max(cur.attempts, ex.total); + } + + // Gene->Outcome prior (independent of signal): stabilizer when signal edges are sparse. + const gEdge = geneOutcomes.get(String(g.id)); + if (gEdge) { + const gx = edgeExpectedSuccess(gEdge, { half_life_days: 45 }); + cur.prior = Math.max(cur.prior, gx.value); + cur.prior_attempts = Math.max(cur.prior_attempts, gx.total); + } + + byGene.set(g.id, cur); + } + } + + for (const [geneId, info] of byGene.entries()) { + const combined = info.best > 0 ? info.best + info.prior * 0.12 : info.prior * 0.4; + scoredGeneIds.push({ geneId, score: combined, attempts: info.attempts, prior: info.prior }); + // Low-efficiency path suppression (unless drift is explicit). + if (!driftEnabled && info.attempts >= 2 && info.best < 0.18) { + bannedGeneIds.add(geneId); + } + // Also suppress genes with consistently poor global outcomes when signal edges are sparse. + if (!driftEnabled && info.attempts < 2 && info.prior_attempts >= 3 && info.prior < 0.12) { + bannedGeneIds.add(geneId); + } + } + + scoredGeneIds.sort((a, b) => b.score - a.score); + const preferredGeneId = scoredGeneIds.length ? scoredGeneIds[0].geneId : null; + + const explanation = []; + if (preferredGeneId) explanation.push(`memory_prefer:${preferredGeneId}`); + if (bannedGeneIds.size) explanation.push(`memory_ban:${Array.from(bannedGeneIds).slice(0, 6).join(',')}`); + if (preferredGeneId) { + const top = scoredGeneIds.find(x => x && x.geneId === preferredGeneId); + if (top && Number.isFinite(Number(top.prior)) && top.prior > 0) explanation.push(`gene_prior:${top.prior.toFixed(3)}`); + } + if (driftEnabled) explanation.push('random_drift:enabled'); + + return { + currentSignalKey: curKey, + preferredGeneId, + bannedGeneIds, + explanation, + }; +} + +function recordSignalSnapshot({ signals, observations }) { + const signalKey = computeSignalKey(signals); + const ts = nowIso(); + const errsig = extractErrorSignatureFromSignals(signals); + const ev = { + type: 'MemoryGraphEvent', + kind: 'signal', + id: `mge_${Date.now()}_${stableHash(`${signalKey}|signal|${ts}`)}`, + ts, + signal: { + key: signalKey, + signals: Array.isArray(signals) ? signals : [], + error_signature: errsig || null, + }, + observed: observations && typeof observations === 'object' ? observations : null, + }; + appendJsonl(memoryGraphPath(), ev); + return ev; +} + +function buildHypothesisText({ signalKey, signals, geneId, geneCategory, driftEnabled }) { + const sigCount = Array.isArray(signals) ? signals.length : 0; + const drift = driftEnabled ? 'drift' : 'directed'; + const g = geneId ? `${geneId}${geneCategory ? `(${geneCategory})` : ''}` : '(none)'; + return `Given signal_key=${signalKey} with ${sigCount} signals, selecting gene=${g} under mode=${drift} is expected to reduce repeated errors and improve stability.`; +} + +function recordHypothesis({ + signals, + mutation, + personality_state, + selectedGene, + selector, + driftEnabled, + selectedBy, + capsulesUsed, + observations, +}) { + const signalKey = computeSignalKey(signals); + const geneId = selectedGene && selectedGene.id ? String(selectedGene.id) : null; + const geneCategory = selectedGene && selectedGene.category ? String(selectedGene.category) : null; + const ts = nowIso(); + const errsig = extractErrorSignatureFromSignals(signals); + const hypothesisId = `hyp_${Date.now()}_${stableHash(`${signalKey}|${geneId || 'none'}|${ts}`)}`; + const personalityState = personality_state || null; + const mutNorm = mutation && isValidMutation(mutation) ? normalizeMutation(mutation) : null; + const psNorm = personalityState && isValidPersonalityState(personalityState) ? normalizePersonalityState(personalityState) : null; + const ev = { + type: 'MemoryGraphEvent', + kind: 'hypothesis', + id: `mge_${Date.now()}_${stableHash(`${hypothesisId}|${ts}`)}`, + ts, + signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [], error_signature: errsig || null }, + hypothesis: { + id: hypothesisId, + text: buildHypothesisText({ signalKey, signals, geneId, geneCategory, driftEnabled }), + predicted_outcome: { status: null, score: null }, + }, + mutation: mutNorm + ? { + id: mutNorm.id, + category: mutNorm.category, + trigger_signals: mutNorm.trigger_signals, + target: mutNorm.target, + expected_effect: mutNorm.expected_effect, + risk_level: mutNorm.risk_level, + } + : null, + personality: psNorm + ? { + key: personalityKey(psNorm), + state: psNorm, + } + : null, + gene: { id: geneId, category: geneCategory }, + action: { + drift: !!driftEnabled, + selected_by: selectedBy || 'selector', + selector: selector || null, + }, + capsules: { + used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [], + }, + observed: observations && typeof observations === 'object' ? observations : null, + }; + appendJsonl(memoryGraphPath(), ev); + return { hypothesisId, signalKey }; +} + +function hasErrorSignal(signals) { + const list = Array.isArray(signals) ? signals : []; + return list.includes('log_error'); +} + +function recordAttempt({ + signals, + mutation, + personality_state, + selectedGene, + selector, + driftEnabled, + selectedBy, + hypothesisId, + capsulesUsed, + observations, +}) { + const signalKey = computeSignalKey(signals); + const geneId = selectedGene && selectedGene.id ? String(selectedGene.id) : null; + const geneCategory = selectedGene && selectedGene.category ? String(selectedGene.category) : null; + const ts = nowIso(); + const errsig = extractErrorSignatureFromSignals(signals); + const actionId = `act_${Date.now()}_${stableHash(`${signalKey}|${geneId || 'none'}|${ts}`)}`; + const personalityState = personality_state || null; + const mutNorm = mutation && isValidMutation(mutation) ? normalizeMutation(mutation) : null; + const psNorm = personalityState && isValidPersonalityState(personalityState) ? normalizePersonalityState(personalityState) : null; + const ev = { + type: 'MemoryGraphEvent', + kind: 'attempt', + id: `mge_${Date.now()}_${stableHash(actionId)}`, + ts, + signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [], error_signature: errsig || null }, + mutation: mutNorm + ? { + id: mutNorm.id, + category: mutNorm.category, + trigger_signals: mutNorm.trigger_signals, + target: mutNorm.target, + expected_effect: mutNorm.expected_effect, + risk_level: mutNorm.risk_level, + } + : null, + personality: psNorm + ? { + key: personalityKey(psNorm), + state: psNorm, + } + : null, + gene: { id: geneId, category: geneCategory }, + hypothesis: hypothesisId ? { id: String(hypothesisId) } : null, + action: { + id: actionId, + drift: !!driftEnabled, + selected_by: selectedBy || 'selector', + selector: selector || null, + }, + capsules: { + used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [], + }, + observed: observations && typeof observations === 'object' ? observations : null, + }; + + appendJsonl(memoryGraphPath(), ev); + + // State is mutable; graph is append-only. + const statePath = memoryGraphStatePath(); + const state = readJsonIfExists(statePath, { last_action: null }); + state.last_action = { + action_id: actionId, + signal_key: signalKey, + signals: Array.isArray(signals) ? signals : [], + mutation_id: mutNorm ? mutNorm.id : null, + mutation_category: mutNorm ? mutNorm.category : null, + mutation_risk_level: mutNorm ? mutNorm.risk_level : null, + personality_key: psNorm ? personalityKey(psNorm) : null, + personality_state: psNorm || null, + gene_id: geneId, + gene_category: geneCategory, + hypothesis_id: hypothesisId ? String(hypothesisId) : null, + capsules_used: Array.isArray(capsulesUsed) ? capsulesUsed.map(String).filter(Boolean) : [], + had_error: hasErrorSignal(signals), + created_at: ts, + outcome_recorded: false, + baseline_observed: observations && typeof observations === 'object' ? observations : null, + }; + writeJsonAtomic(statePath, state); + + return { actionId, signalKey }; +} + +function inferOutcomeFromSignals({ prevHadError, currentHasError }) { + if (prevHadError && !currentHasError) return { status: 'success', score: 0.85, note: 'error_cleared' }; + if (prevHadError && currentHasError) return { status: 'failed', score: 0.2, note: 'error_persisted' }; + if (!prevHadError && currentHasError) return { status: 'failed', score: 0.15, note: 'new_error_appeared' }; + return { status: 'success', score: 0.6, note: 'stable_no_error' }; +} + +function clamp01(x) { + const n = Number(x); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +function tryParseLastEvolutionEventOutcome(evidenceText) { + // Scan tail text for an EvolutionEvent JSON line and extract its outcome. + const s = String(evidenceText || ''); + if (!s) return null; + const lines = s.split('\n').slice(-400); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + if (!line.includes('"type"') || !line.includes('EvolutionEvent')) continue; + try { + const obj = JSON.parse(line); + if (!obj || obj.type !== 'EvolutionEvent') continue; + const o = obj.outcome && typeof obj.outcome === 'object' ? obj.outcome : null; + if (!o) continue; + const status = o.status === 'success' || o.status === 'failed' ? o.status : null; + const score = Number.isFinite(Number(o.score)) ? clamp01(Number(o.score)) : null; + if (!status && score == null) continue; + return { + status: status || (score != null && score >= 0.5 ? 'success' : 'failed'), + score: score != null ? score : status === 'success' ? 0.75 : 0.25, + note: 'evolutionevent_observed', + }; + } catch (e) { + continue; + } + } + return null; +} + +function inferOutcomeEnhanced({ prevHadError, currentHasError, baselineObserved, currentObserved }) { + const evidence = + currentObserved && + currentObserved.evidence && + (currentObserved.evidence.recent_session_tail || currentObserved.evidence.today_log_tail) + ? currentObserved.evidence + : null; + const combinedEvidence = evidence + ? `${String(evidence.recent_session_tail || '')}\n${String(evidence.today_log_tail || '')}` + : ''; + const observed = tryParseLastEvolutionEventOutcome(combinedEvidence); + if (observed) return observed; + + const base = inferOutcomeFromSignals({ prevHadError, currentHasError }); + + const prevErrCount = + baselineObserved && Number.isFinite(Number(baselineObserved.recent_error_count)) + ? Number(baselineObserved.recent_error_count) + : null; + const curErrCount = + currentObserved && Number.isFinite(Number(currentObserved.recent_error_count)) + ? Number(currentObserved.recent_error_count) + : null; + + let score = base.score; + if (prevErrCount != null && curErrCount != null) { + const delta = prevErrCount - curErrCount; + score += Math.max(-0.12, Math.min(0.12, delta / 50)); + } + + const prevScan = + baselineObserved && Number.isFinite(Number(baselineObserved.scan_ms)) ? Number(baselineObserved.scan_ms) : null; + const curScan = + currentObserved && Number.isFinite(Number(currentObserved.scan_ms)) ? Number(currentObserved.scan_ms) : null; + if (prevScan != null && curScan != null && prevScan > 0) { + const ratio = (prevScan - curScan) / prevScan; + score += Math.max(-0.06, Math.min(0.06, ratio)); + } + + return { status: base.status, score: clamp01(score), note: `${base.note}|heuristic_delta` }; +} + +function buildConfidenceEdgeEvent({ signalKey, signals, geneId, geneCategory, outcomeEventId, halfLifeDays }) { + const events = tryReadMemoryGraphEvents(2000); + const edges = aggregateEdges(events); + const k = `${signalKey}::${geneId}`; + const edge = edges.get(k) || { success: 0, fail: 0, last_ts: null }; + const ex = edgeExpectedSuccess(edge, { half_life_days: halfLifeDays }); + const ts = nowIso(); + return { + type: 'MemoryGraphEvent', + kind: 'confidence_edge', + id: `mge_${Date.now()}_${stableHash(`${signalKey}|${geneId}|confidence|${ts}`)}`, + ts, + signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [] }, + gene: { id: geneId, category: geneCategory || null }, + edge: { signal_key: signalKey, gene_id: geneId }, + stats: { + success: Number(edge.success) || 0, + fail: Number(edge.fail) || 0, + attempts: Number(ex.total) || 0, + p: ex.p, + decay_weight: ex.w, + value: ex.value, + half_life_days: halfLifeDays, + updated_at: ts, + }, + derived_from: { outcome_event_id: outcomeEventId || null }, + }; +} + +function buildGeneOutcomeConfidenceEvent({ geneId, geneCategory, outcomeEventId, halfLifeDays }) { + const events = tryReadMemoryGraphEvents(2000); + const geneOutcomes = aggregateGeneOutcomes(events); + const edge = geneOutcomes.get(String(geneId)) || { success: 0, fail: 0, last_ts: null }; + const ex = edgeExpectedSuccess(edge, { half_life_days: halfLifeDays }); + const ts = nowIso(); + return { + type: 'MemoryGraphEvent', + kind: 'confidence_gene_outcome', + id: `mge_${Date.now()}_${stableHash(`${geneId}|gene_outcome|confidence|${ts}`)}`, + ts, + gene: { id: String(geneId), category: geneCategory || null }, + edge: { gene_id: String(geneId) }, + stats: { + success: Number(edge.success) || 0, + fail: Number(edge.fail) || 0, + attempts: Number(ex.total) || 0, + p: ex.p, + decay_weight: ex.w, + value: ex.value, + half_life_days: halfLifeDays, + updated_at: ts, + }, + derived_from: { outcome_event_id: outcomeEventId || null }, + }; +} + +function recordOutcomeFromState({ signals, observations }) { + const statePath = memoryGraphStatePath(); + const state = readJsonIfExists(statePath, { last_action: null }); + const last = state && state.last_action ? state.last_action : null; + if (!last || !last.action_id) return null; + if (last.outcome_recorded) return null; + + const currentHasError = hasErrorSignal(signals); + const inferred = inferOutcomeEnhanced({ + prevHadError: !!last.had_error, + currentHasError, + baselineObserved: last.baseline_observed || null, + currentObserved: observations || null, + }); + const ts = nowIso(); + const errsig = extractErrorSignatureFromSignals(signals); + const ev = { + type: 'MemoryGraphEvent', + kind: 'outcome', + id: `mge_${Date.now()}_${stableHash(`${last.action_id}|outcome|${ts}`)}`, + ts, + signal: { + key: String(last.signal_key || '(none)'), + signals: Array.isArray(last.signals) ? last.signals : [], + error_signature: errsig || null, + }, + mutation: + last.mutation_id || last.mutation_category || last.mutation_risk_level + ? { + id: last.mutation_id || null, + category: last.mutation_category || null, + risk_level: last.mutation_risk_level || null, + } + : null, + personality: + last.personality_key || last.personality_state + ? { + key: last.personality_key || null, + state: last.personality_state || null, + } + : null, + gene: { id: last.gene_id || null, category: last.gene_category || null }, + action: { id: String(last.action_id) }, + hypothesis: last.hypothesis_id ? { id: String(last.hypothesis_id) } : null, + outcome: { + status: inferred.status, + score: inferred.score, + note: inferred.note, + observed: { current_signals: Array.isArray(signals) ? signals : [] }, + }, + confidence: { + // This is an interpretable, decayed success estimate derived from outcomes; aggregation is computed at read-time. + half_life_days: 30, + }, + observed: observations && typeof observations === 'object' ? observations : null, + baseline: last.baseline_observed || null, + capsules: { + used: Array.isArray(last.capsules_used) ? last.capsules_used : [], + }, + }; + + appendJsonl(memoryGraphPath(), ev); + + // Persist explicit confidence snapshots (append-only) for auditability. + try { + if (last.gene_id) { + const edgeEv = buildConfidenceEdgeEvent({ + signalKey: String(last.signal_key || '(none)'), + signals: Array.isArray(last.signals) ? last.signals : [], + geneId: String(last.gene_id), + geneCategory: last.gene_category || null, + outcomeEventId: ev.id, + halfLifeDays: 30, + }); + appendJsonl(memoryGraphPath(), edgeEv); + + const geneEv = buildGeneOutcomeConfidenceEvent({ + geneId: String(last.gene_id), + geneCategory: last.gene_category || null, + outcomeEventId: ev.id, + halfLifeDays: 45, + }); + appendJsonl(memoryGraphPath(), geneEv); + } + } catch (e) {} + + last.outcome_recorded = true; + last.outcome_recorded_at = ts; + state.last_action = last; + writeJsonAtomic(statePath, state); + + return ev; +} + +function recordExternalCandidate({ asset, source, signals }) { + // Append-only annotation: external assets enter as candidates only. + // This does not affect outcome aggregation (which only uses kind === 'outcome'). + const a = asset && typeof asset === 'object' ? asset : null; + const type = a && a.type ? String(a.type) : null; + const id = a && a.id ? String(a.id) : null; + if (!type || !id) return null; + + const ts = nowIso(); + const signalKey = computeSignalKey(signals); + const ev = { + type: 'MemoryGraphEvent', + kind: 'external_candidate', + id: `mge_${Date.now()}_${stableHash(`${type}|${id}|external|${ts}`)}`, + ts, + signal: { key: signalKey, signals: Array.isArray(signals) ? signals : [] }, + external: { + source: source || 'external', + received_at: ts, + }, + asset: { type, id }, + candidate: { + // Minimal hints for later local triggering/validation. + trigger: type === 'Capsule' && Array.isArray(a.trigger) ? a.trigger : [], + gene: type === 'Capsule' && a.gene ? String(a.gene) : null, + confidence: type === 'Capsule' && Number.isFinite(Number(a.confidence)) ? Number(a.confidence) : null, + }, + }; + + appendJsonl(memoryGraphPath(), ev); + return ev; +} + +module.exports = { + memoryGraphPath, + computeSignalKey, + tryReadMemoryGraphEvents, + getMemoryAdvice, + recordSignalSnapshot, + recordHypothesis, + recordAttempt, + recordOutcomeFromState, + recordExternalCandidate, +}; + diff --git a/skills/capability-evolver/src/gep/memoryGraphAdapter.js b/skills/capability-evolver/src/gep/memoryGraphAdapter.js new file mode 100644 index 0000000..15b79d3 --- /dev/null +++ b/skills/capability-evolver/src/gep/memoryGraphAdapter.js @@ -0,0 +1,203 @@ +// --------------------------------------------------------------------------- +// MemoryGraphAdapter -- stable interface boundary for memory graph operations. +// +// Default implementation delegates to the local JSONL-based memoryGraph.js. +// SaaS providers can supply a remote adapter by setting MEMORY_GRAPH_PROVIDER=remote +// and configuring MEMORY_GRAPH_REMOTE_URL / MEMORY_GRAPH_REMOTE_KEY. +// +// The adapter is designed so that the open-source evolver always works offline +// with the local implementation. Remote is optional and degrades gracefully. +// --------------------------------------------------------------------------- + +const localGraph = require('./memoryGraph'); + +// --------------------------------------------------------------------------- +// Adapter interface contract (all methods must be implemented by providers): +// +// getAdvice({ signals, genes, driftEnabled }) => { preferredGeneId, bannedGeneIds, currentSignalKey, explanation } +// recordSignalSnapshot({ signals, observations }) => event +// recordHypothesis({ signals, mutation, personality_state, selectedGene, selector, driftEnabled, selectedBy, capsulesUsed, observations }) => { hypothesisId, signalKey } +// recordAttempt({ signals, mutation, personality_state, selectedGene, selector, driftEnabled, selectedBy, hypothesisId, capsulesUsed, observations }) => { actionId, signalKey } +// recordOutcome({ signals, observations }) => event | null +// recordExternalCandidate({ asset, source, signals }) => event | null +// memoryGraphPath() => string +// computeSignalKey(signals) => string +// tryReadMemoryGraphEvents(limit) => event[] +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Local adapter (default) -- wraps memoryGraph.js without any behavior change +// --------------------------------------------------------------------------- + +const localAdapter = { + name: 'local', + + getAdvice(opts) { + return localGraph.getMemoryAdvice(opts); + }, + + recordSignalSnapshot(opts) { + return localGraph.recordSignalSnapshot(opts); + }, + + recordHypothesis(opts) { + return localGraph.recordHypothesis(opts); + }, + + recordAttempt(opts) { + return localGraph.recordAttempt(opts); + }, + + recordOutcome(opts) { + return localGraph.recordOutcomeFromState(opts); + }, + + recordExternalCandidate(opts) { + return localGraph.recordExternalCandidate(opts); + }, + + memoryGraphPath() { + return localGraph.memoryGraphPath(); + }, + + computeSignalKey(signals) { + return localGraph.computeSignalKey(signals); + }, + + tryReadMemoryGraphEvents(limit) { + return localGraph.tryReadMemoryGraphEvents(limit); + }, +}; + +// --------------------------------------------------------------------------- +// Remote adapter (SaaS) -- calls external KG service with local fallback +// --------------------------------------------------------------------------- + +function buildRemoteAdapter() { + const remoteUrl = process.env.MEMORY_GRAPH_REMOTE_URL || ''; + const remoteKey = process.env.MEMORY_GRAPH_REMOTE_KEY || ''; + const timeoutMs = Number(process.env.MEMORY_GRAPH_REMOTE_TIMEOUT_MS) || 5000; + + async function remoteCall(endpoint, body) { + if (!remoteUrl) throw new Error('MEMORY_GRAPH_REMOTE_URL not configured'); + const url = `${remoteUrl.replace(/\/+$/, '')}${endpoint}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(remoteKey ? { Authorization: `Bearer ${remoteKey}` } : {}), + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`remote_kg_error: ${res.status}`); + } + return await res.json(); + } finally { + clearTimeout(timer); + } + } + + // Wrap remote call with local fallback -- ensures offline resilience. + function withFallback(localFn, remoteFn) { + return async function (...args) { + try { + return await remoteFn(...args); + } catch (e) { + // Fallback to local on any remote failure (network, timeout, config). + return localFn(...args); + } + }; + } + + return { + name: 'remote', + + // getAdvice is the primary candidate for remote enhancement (richer graph reasoning). + getAdvice: withFallback( + (opts) => localGraph.getMemoryAdvice(opts), + async (opts) => { + const result = await remoteCall('/kg/advice', { + signals: opts.signals, + genes: (opts.genes || []).map((g) => ({ id: g.id, category: g.category, type: g.type })), + driftEnabled: opts.driftEnabled, + }); + // Normalize remote response to match local contract. + return { + currentSignalKey: result.currentSignalKey || localGraph.computeSignalKey(opts.signals), + preferredGeneId: result.preferredGeneId || null, + bannedGeneIds: new Set(result.bannedGeneIds || []), + explanation: Array.isArray(result.explanation) ? result.explanation : [], + }; + } + ), + + // Write operations: always write locally first, then async-sync to remote. + // This preserves the append-only local graph as source of truth. + recordSignalSnapshot(opts) { + const ev = localGraph.recordSignalSnapshot(opts); + remoteCall('/kg/ingest', { kind: 'signal', event: ev }).catch(() => {}); + return ev; + }, + + recordHypothesis(opts) { + const result = localGraph.recordHypothesis(opts); + remoteCall('/kg/ingest', { kind: 'hypothesis', event: result }).catch(() => {}); + return result; + }, + + recordAttempt(opts) { + const result = localGraph.recordAttempt(opts); + remoteCall('/kg/ingest', { kind: 'attempt', event: result }).catch(() => {}); + return result; + }, + + recordOutcome(opts) { + const ev = localGraph.recordOutcomeFromState(opts); + if (ev) { + remoteCall('/kg/ingest', { kind: 'outcome', event: ev }).catch(() => {}); + } + return ev; + }, + + recordExternalCandidate(opts) { + const ev = localGraph.recordExternalCandidate(opts); + if (ev) { + remoteCall('/kg/ingest', { kind: 'external_candidate', event: ev }).catch(() => {}); + } + return ev; + }, + + memoryGraphPath() { + return localGraph.memoryGraphPath(); + }, + + computeSignalKey(signals) { + return localGraph.computeSignalKey(signals); + }, + + tryReadMemoryGraphEvents(limit) { + return localGraph.tryReadMemoryGraphEvents(limit); + }, + }; +} + +// --------------------------------------------------------------------------- +// Provider resolution +// --------------------------------------------------------------------------- + +function resolveAdapter() { + const provider = (process.env.MEMORY_GRAPH_PROVIDER || 'local').toLowerCase().trim(); + if (provider === 'remote') { + return buildRemoteAdapter(); + } + return localAdapter; +} + +const adapter = resolveAdapter(); + +module.exports = adapter; diff --git a/skills/capability-evolver/src/gep/mutation.js b/skills/capability-evolver/src/gep/mutation.js new file mode 100644 index 0000000..a7c6bed --- /dev/null +++ b/skills/capability-evolver/src/gep/mutation.js @@ -0,0 +1,186 @@ +function clamp01(x) { + const n = Number(x); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +function nowTsMs() { + return Date.now(); +} + +function uniqStrings(list) { + const out = []; + const seen = new Set(); + for (const x of Array.isArray(list) ? list : []) { + const s = String(x || '').trim(); + if (!s) continue; + if (seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +function hasErrorishSignal(signals) { + const list = Array.isArray(signals) ? signals.map(s => String(s || '')) : []; + if (list.includes('issue_already_resolved') || list.includes('openclaw_self_healed')) return false; + if (list.includes('log_error')) return true; + if (list.some(s => s.startsWith('errsig:') || s.startsWith('errsig_norm:'))) return true; + return false; +} + +// Opportunity signals that indicate a chance to innovate (not just fix). +var OPPORTUNITY_SIGNALS = [ + 'user_feature_request', + 'user_improvement_suggestion', + 'perf_bottleneck', + 'capability_gap', + 'stable_success_plateau', + 'external_opportunity', + 'issue_already_resolved', + 'openclaw_self_healed', + 'empty_cycle_loop_detected', +]; + +function hasOpportunitySignal(signals) { + var list = Array.isArray(signals) ? signals.map(function (s) { return String(s || ''); }) : []; + for (var i = 0; i < OPPORTUNITY_SIGNALS.length; i++) { + var name = OPPORTUNITY_SIGNALS[i]; + if (list.includes(name)) return true; + if (list.some(function (s) { return s.startsWith(name + ':'); })) return true; + } + return false; +} + +function mutationCategoryFromContext({ signals, driftEnabled }) { + if (hasErrorishSignal(signals)) return 'repair'; + if (driftEnabled) return 'innovate'; + // Auto-innovate: opportunity signals present and no errors + if (hasOpportunitySignal(signals)) return 'innovate'; + // Consult strategy preset: if the configured strategy favors innovation, + // default to innovate instead of optimize when there is nothing specific to do. + try { + var strategy = require('./strategy').resolveStrategy(); + if (strategy && typeof strategy.innovate === 'number' && strategy.innovate >= 0.5) return 'innovate'; + } catch (_) {} + return 'optimize'; +} + +function expectedEffectFromCategory(category) { + const c = String(category || ''); + if (c === 'repair') return 'reduce runtime errors, increase stability, and lower failure rate'; + if (c === 'optimize') return 'improve success rate and reduce repeated operational cost'; + if (c === 'innovate') return 'explore new strategy combinations to escape local optimum'; + return 'improve robustness and success probability'; +} + +function targetFromGene(selectedGene) { + if (selectedGene && selectedGene.id) return `gene:${String(selectedGene.id)}`; + return 'behavior:protocol'; +} + +function isHighRiskPersonality(p) { + // Conservative definition: low rigor or high risk_tolerance is treated as high-risk personality. + const rigor = p && Number.isFinite(Number(p.rigor)) ? Number(p.rigor) : null; + const riskTol = p && Number.isFinite(Number(p.risk_tolerance)) ? Number(p.risk_tolerance) : null; + if (rigor != null && rigor < 0.5) return true; + if (riskTol != null && riskTol > 0.6) return true; + return false; +} + +function isHighRiskMutationAllowed(personalityState) { + const rigor = personalityState && Number.isFinite(Number(personalityState.rigor)) ? Number(personalityState.rigor) : 0; + const riskTol = + personalityState && Number.isFinite(Number(personalityState.risk_tolerance)) + ? Number(personalityState.risk_tolerance) + : 1; + return rigor >= 0.6 && riskTol <= 0.5; +} + +function buildMutation({ + signals, + selectedGene, + driftEnabled, + personalityState, + allowHighRisk = false, + target, + expected_effect, +} = {}) { + const ts = nowTsMs(); + const category = mutationCategoryFromContext({ signals, driftEnabled: !!driftEnabled }); + const triggerSignals = uniqStrings(signals); + + const base = { + type: 'Mutation', + id: `mut_${ts}`, + category, + trigger_signals: triggerSignals, + target: String(target || targetFromGene(selectedGene)), + expected_effect: String(expected_effect || expectedEffectFromCategory(category)), + risk_level: 'low', + }; + + // Default risk assignment: innovate is medium; others low. + if (category === 'innovate') base.risk_level = 'medium'; + + // Optional high-risk escalation (rare, and guarded by strict safety constraints). + if (allowHighRisk && category === 'innovate') { + base.risk_level = 'high'; + } + + // Safety constraints (hard): + // - forbid innovate + high-risk personality (downgrade innovation to optimize) + // - forbid high-risk mutation unless personality satisfies constraints + const highRiskPersonality = isHighRiskPersonality(personalityState || null); + if (base.category === 'innovate' && highRiskPersonality) { + base.category = 'optimize'; + base.expected_effect = 'safety downgrade: optimize under high-risk personality (avoid innovate+high-risk combo)'; + base.risk_level = 'low'; + base.trigger_signals = uniqStrings([...(base.trigger_signals || []), 'safety:avoid_innovate_with_high_risk_personality']); + } + + if (base.risk_level === 'high' && !isHighRiskMutationAllowed(personalityState || null)) { + // Downgrade rather than emit illegal high-risk mutation. + base.risk_level = 'medium'; + base.trigger_signals = uniqStrings([...(base.trigger_signals || []), 'safety:downgrade_high_risk']); + } + + return base; +} + +function isValidMutation(obj) { + if (!obj || typeof obj !== 'object') return false; + if (obj.type !== 'Mutation') return false; + if (!obj.id || typeof obj.id !== 'string') return false; + if (!obj.category || !['repair', 'optimize', 'innovate'].includes(String(obj.category))) return false; + if (!Array.isArray(obj.trigger_signals)) return false; + if (!obj.target || typeof obj.target !== 'string') return false; + if (!obj.expected_effect || typeof obj.expected_effect !== 'string') return false; + if (!obj.risk_level || !['low', 'medium', 'high'].includes(String(obj.risk_level))) return false; + return true; +} + +function normalizeMutation(obj) { + const m = obj && typeof obj === 'object' ? obj : {}; + const out = { + type: 'Mutation', + id: typeof m.id === 'string' ? m.id : `mut_${nowTsMs()}`, + category: ['repair', 'optimize', 'innovate'].includes(String(m.category)) ? String(m.category) : 'optimize', + trigger_signals: uniqStrings(m.trigger_signals), + target: typeof m.target === 'string' ? m.target : 'behavior:protocol', + expected_effect: typeof m.expected_effect === 'string' ? m.expected_effect : expectedEffectFromCategory(m.category), + risk_level: ['low', 'medium', 'high'].includes(String(m.risk_level)) ? String(m.risk_level) : 'low', + }; + return out; +} + +module.exports = { + clamp01, + buildMutation, + isValidMutation, + normalizeMutation, + isHighRiskMutationAllowed, + isHighRiskPersonality, + hasOpportunitySignal, +}; + diff --git a/skills/capability-evolver/src/gep/paths.js b/skills/capability-evolver/src/gep/paths.js new file mode 100644 index 0000000..01218dd --- /dev/null +++ b/skills/capability-evolver/src/gep/paths.js @@ -0,0 +1,67 @@ +const path = require('path'); + +function getRepoRoot() { + // src/gep/paths.js -> repo root + return path.resolve(__dirname, '..', '..'); +} + +function getWorkspaceRoot() { + // skills/evolver -> workspace root + return path.resolve(getRepoRoot(), '..', '..'); +} + +function getLogsDir() { + return process.env.EVOLVER_LOGS_DIR || path.join(getWorkspaceRoot(), 'logs'); +} + +function getMemoryDir() { + return process.env.MEMORY_DIR || path.join(getWorkspaceRoot(), 'memory'); +} + +// --- Session Scope Isolation --- +// When EVOLVER_SESSION_SCOPE is set (e.g., to a Discord channel ID or project name), +// evolution state, memory graph, and assets are isolated to a per-scope subdirectory. +// This prevents cross-channel/cross-project memory contamination. +// When NOT set, everything works as before (global scope, backward compatible). +function getSessionScope() { + const raw = String(process.env.EVOLVER_SESSION_SCOPE || '').trim(); + if (!raw) return null; + // Sanitize: only allow alphanumeric, dash, underscore, dot (prevent path traversal). + const safe = raw.replace(/[^a-zA-Z0-9_\-\.]/g, '_').slice(0, 128); + return safe || null; +} + +function getEvolutionDir() { + const baseDir = process.env.EVOLUTION_DIR || path.join(getMemoryDir(), 'evolution'); + const scope = getSessionScope(); + if (scope) { + return path.join(baseDir, 'scopes', scope); + } + return baseDir; +} + +function getGepAssetsDir() { + const repoRoot = getRepoRoot(); + const baseDir = process.env.GEP_ASSETS_DIR || path.join(repoRoot, 'assets', 'gep'); + const scope = getSessionScope(); + if (scope) { + return path.join(baseDir, 'scopes', scope); + } + return baseDir; +} + +function getSkillsDir() { + return process.env.SKILLS_DIR || path.join(getWorkspaceRoot(), 'skills'); +} + +module.exports = { + getRepoRoot, + getWorkspaceRoot, + getLogsDir, + getMemoryDir, + getEvolutionDir, + getGepAssetsDir, + getSkillsDir, + getSessionScope, +}; + diff --git a/skills/capability-evolver/src/gep/personality.js b/skills/capability-evolver/src/gep/personality.js new file mode 100644 index 0000000..7923dac --- /dev/null +++ b/skills/capability-evolver/src/gep/personality.js @@ -0,0 +1,355 @@ +const fs = require('fs'); +const path = require('path'); +const { getMemoryDir } = require('./paths'); +const { hasOpportunitySignal } = require('./mutation'); + +function nowIso() { + return new Date().toISOString(); +} + +function clamp01(x) { + const n = Number(x); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (e) {} +} + +function readJsonIfExists(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function writeJsonAtomic(filePath, obj) { + const dir = path.dirname(filePath); + ensureDir(dir); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, filePath); +} + +function personalityFilePath() { + const memoryDir = getMemoryDir(); + const { getEvolutionDir } = require('./paths'); return path.join(getEvolutionDir(), 'personality_state.json'); +} + +function defaultPersonalityState() { + // Conservative defaults: protocol-first, safe, low-risk. + return { + type: 'PersonalityState', + rigor: 0.7, + creativity: 0.35, + verbosity: 0.25, + risk_tolerance: 0.4, + obedience: 0.85, + }; +} + +function normalizePersonalityState(state) { + const s = state && typeof state === 'object' ? state : {}; + return { + type: 'PersonalityState', + rigor: clamp01(s.rigor), + creativity: clamp01(s.creativity), + verbosity: clamp01(s.verbosity), + risk_tolerance: clamp01(s.risk_tolerance), + obedience: clamp01(s.obedience), + }; +} + +function isValidPersonalityState(obj) { + if (!obj || typeof obj !== 'object') return false; + if (obj.type !== 'PersonalityState') return false; + for (const k of ['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience']) { + const v = obj[k]; + if (!Number.isFinite(Number(v))) return false; + const n = Number(v); + if (n < 0 || n > 1) return false; + } + return true; +} + +function roundToStep(x, step) { + const s = Number(step); + if (!Number.isFinite(s) || s <= 0) return x; + return Math.round(Number(x) / s) * s; +} + +function personalityKey(state) { + const s = normalizePersonalityState(state); + const step = 0.1; + const r = roundToStep(s.rigor, step).toFixed(1); + const c = roundToStep(s.creativity, step).toFixed(1); + const v = roundToStep(s.verbosity, step).toFixed(1); + const rt = roundToStep(s.risk_tolerance, step).toFixed(1); + const o = roundToStep(s.obedience, step).toFixed(1); + return `rigor=${r}|creativity=${c}|verbosity=${v}|risk_tolerance=${rt}|obedience=${o}`; +} + +function getParamDeltas(fromState, toState) { + const a = normalizePersonalityState(fromState); + const b = normalizePersonalityState(toState); + const deltas = []; + for (const k of ['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience']) { + deltas.push({ param: k, delta: Number(b[k]) - Number(a[k]) }); + } + deltas.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + return deltas; +} + +function personalityScore(statsEntry) { + const e = statsEntry && typeof statsEntry === 'object' ? statsEntry : {}; + const succ = Number(e.success) || 0; + const fail = Number(e.fail) || 0; + const total = succ + fail; + // Laplace-smoothed success probability + const p = (succ + 1) / (total + 2); + // Penalize tiny-sample overconfidence + const sampleWeight = Math.min(1, total / 8); + // Use avg_score (if present) as mild quality proxy + const avg = Number.isFinite(Number(e.avg_score)) ? Number(e.avg_score) : null; + const q = avg == null ? 0.5 : clamp01(avg); + return p * 0.75 + q * 0.25 * sampleWeight; +} + +function chooseBestKnownPersonality(statsByKey) { + const stats = statsByKey && typeof statsByKey === 'object' ? statsByKey : {}; + let best = null; + for (const [k, entry] of Object.entries(stats)) { + const e = entry || {}; + const total = (Number(e.success) || 0) + (Number(e.fail) || 0); + if (total < 3) continue; + const sc = personalityScore(e); + if (!best || sc > best.score) best = { key: k, score: sc, entry: e }; + } + return best; +} + +function parseKeyToState(key) { + // key format: rigor=0.7|creativity=0.3|... + const out = defaultPersonalityState(); + const parts = String(key || '').split('|').map(s => s.trim()).filter(Boolean); + for (const p of parts) { + const [k, v] = p.split('=').map(x => String(x || '').trim()); + if (!k) continue; + if (!['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience'].includes(k)) continue; + out[k] = clamp01(Number(v)); + } + return normalizePersonalityState(out); +} + +function applyPersonalityMutations(state, mutations) { + let cur = normalizePersonalityState(state); + const muts = Array.isArray(mutations) ? mutations : []; + const applied = []; + let count = 0; + for (const m of muts) { + if (!m || typeof m !== 'object') continue; + const param = String(m.param || '').trim(); + if (!['rigor', 'creativity', 'verbosity', 'risk_tolerance', 'obedience'].includes(param)) continue; + const delta = Number(m.delta); + if (!Number.isFinite(delta)) continue; + const clipped = Math.max(-0.2, Math.min(0.2, delta)); + cur[param] = clamp01(Number(cur[param]) + clipped); + applied.push({ type: 'PersonalityMutation', param, delta: clipped, reason: String(m.reason || '').slice(0, 140) }); + count += 1; + if (count >= 2) break; + } + return { state: cur, applied }; +} + +function proposeMutations({ baseState, reason, driftEnabled, signals }) { + const s = normalizePersonalityState(baseState); + const sig = Array.isArray(signals) ? signals.map(x => String(x || '')) : []; + const muts = []; + + const r = String(reason || ''); + if (driftEnabled) { + muts.push({ type: 'PersonalityMutation', param: 'creativity', delta: +0.1, reason: r || 'drift enabled' }); + // Keep risk bounded under drift by default. + muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: -0.05, reason: 'drift safety clamp' }); + } else if (sig.includes('protocol_drift')) { + muts.push({ type: 'PersonalityMutation', param: 'obedience', delta: +0.1, reason: r || 'protocol drift' }); + muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.05, reason: 'tighten protocol compliance' }); + } else if (sig.includes('log_error') || sig.some(x => x.startsWith('errsig:') || x.startsWith('errsig_norm:'))) { + muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.1, reason: r || 'repair instability' }); + muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: -0.1, reason: 'reduce risky changes under errors' }); + } else if (hasOpportunitySignal(sig)) { + // Opportunity detected: nudge towards creativity to enable innovation. + muts.push({ type: 'PersonalityMutation', param: 'creativity', delta: +0.1, reason: r || 'opportunity signal detected' }); + muts.push({ type: 'PersonalityMutation', param: 'risk_tolerance', delta: +0.05, reason: 'allow exploration for innovation' }); + } else { + // Plateau-like generic: slightly increase rigor, slightly decrease verbosity (more concise execution). + muts.push({ type: 'PersonalityMutation', param: 'rigor', delta: +0.05, reason: r || 'stability bias' }); + muts.push({ type: 'PersonalityMutation', param: 'verbosity', delta: -0.05, reason: 'reduce noise' }); + } + + // If already very high obedience, avoid pushing it further; swap second mutation to creativity. + if (s.obedience >= 0.95) { + const idx = muts.findIndex(x => x.param === 'obedience'); + if (idx >= 0) muts[idx] = { type: 'PersonalityMutation', param: 'creativity', delta: +0.05, reason: 'obedience saturated' }; + } + return muts; +} + +function shouldTriggerPersonalityMutation({ driftEnabled, recentEvents }) { + if (driftEnabled) return { ok: true, reason: 'drift enabled' }; + const list = Array.isArray(recentEvents) ? recentEvents : []; + const tail = list.slice(-6); + const outcomes = tail + .map(e => (e && e.outcome && e.outcome.status ? String(e.outcome.status) : null)) + .filter(Boolean); + if (outcomes.length >= 4) { + const recentFailed = outcomes.slice(-4).filter(x => x === 'failed').length; + if (recentFailed >= 3) return { ok: true, reason: 'long failure streak' }; + } + // Mutation consecutive failure proxy: last 3 events that have mutation_id. + const withMut = tail.filter(e => e && typeof e.mutation_id === 'string' && e.mutation_id); + if (withMut.length >= 3) { + const last3 = withMut.slice(-3); + const fail3 = last3.filter(e => e && e.outcome && e.outcome.status === 'failed').length; + if (fail3 >= 3) return { ok: true, reason: 'mutation consecutive failures' }; + } + return { ok: false, reason: '' }; +} + +function loadPersonalityModel() { + const p = personalityFilePath(); + const fallback = { + version: 1, + current: defaultPersonalityState(), + stats: {}, + history: [], + updated_at: nowIso(), + }; + const raw = readJsonIfExists(p, fallback); + const cur = normalizePersonalityState(raw && raw.current ? raw.current : defaultPersonalityState()); + const stats = raw && typeof raw.stats === 'object' ? raw.stats : {}; + const history = Array.isArray(raw && raw.history) ? raw.history : []; + return { version: 1, current: cur, stats, history, updated_at: raw && raw.updated_at ? raw.updated_at : nowIso() }; +} + +function savePersonalityModel(model) { + const m = model && typeof model === 'object' ? model : {}; + const out = { + version: 1, + current: normalizePersonalityState(m.current || defaultPersonalityState()), + stats: m.stats && typeof m.stats === 'object' ? m.stats : {}, + history: Array.isArray(m.history) ? m.history.slice(-120) : [], + updated_at: nowIso(), + }; + writeJsonAtomic(personalityFilePath(), out); + return out; +} + +function selectPersonalityForRun({ driftEnabled, signals, recentEvents } = {}) { + const model = loadPersonalityModel(); + const base = normalizePersonalityState(model.current); + const stats = model.stats || {}; + + const best = chooseBestKnownPersonality(stats); + let naturalSelectionApplied = []; + + // Natural selection: nudge towards the best-known configuration (small, max 2 params). + if (best && best.key) { + const bestState = parseKeyToState(best.key); + const diffs = getParamDeltas(base, bestState).filter(d => Math.abs(d.delta) >= 0.05); + const muts = []; + for (const d of diffs.slice(0, 2)) { + const clipped = Math.max(-0.1, Math.min(0.1, d.delta)); + muts.push({ type: 'PersonalityMutation', param: d.param, delta: clipped, reason: 'natural_selection' }); + } + const applied = applyPersonalityMutations(base, muts); + model.current = applied.state; + naturalSelectionApplied = applied.applied; + } + + // Triggered personality mutation (explicit rule-based). + const trig = shouldTriggerPersonalityMutation({ driftEnabled: !!driftEnabled, recentEvents }); + let triggeredApplied = []; + if (trig.ok) { + const props = proposeMutations({ + baseState: model.current, + reason: trig.reason, + driftEnabled: !!driftEnabled, + signals, + }); + const applied = applyPersonalityMutations(model.current, props); + model.current = applied.state; + triggeredApplied = applied.applied; + } + + // Persist updated current state. + const saved = savePersonalityModel(model); + const key = personalityKey(saved.current); + const known = !!(saved.stats && saved.stats[key]); + + return { + personality_state: saved.current, + personality_key: key, + personality_known: known, + personality_mutations: [...naturalSelectionApplied, ...triggeredApplied], + model_meta: { + best_known_key: best && best.key ? best.key : null, + best_known_score: best && Number.isFinite(Number(best.score)) ? Number(best.score) : null, + triggered: trig.ok ? { reason: trig.reason } : null, + }, + }; +} + +function updatePersonalityStats({ personalityState, outcome, score, notes } = {}) { + const model = loadPersonalityModel(); + const st = normalizePersonalityState(personalityState || model.current); + const key = personalityKey(st); + if (!model.stats || typeof model.stats !== 'object') model.stats = {}; + const cur = model.stats[key] && typeof model.stats[key] === 'object' ? model.stats[key] : { success: 0, fail: 0, avg_score: 0.5, n: 0 }; + + const out = String(outcome || '').toLowerCase(); + if (out === 'success') cur.success = (Number(cur.success) || 0) + 1; + else if (out === 'failed') cur.fail = (Number(cur.fail) || 0) + 1; + + const sc = Number.isFinite(Number(score)) ? clamp01(Number(score)) : null; + if (sc != null) { + const n = (Number(cur.n) || 0) + 1; + const prev = Number.isFinite(Number(cur.avg_score)) ? Number(cur.avg_score) : 0.5; + cur.avg_score = prev + (sc - prev) / n; + cur.n = n; + } + cur.updated_at = nowIso(); + model.stats[key] = cur; + + model.history = Array.isArray(model.history) ? model.history : []; + model.history.push({ + at: nowIso(), + key, + outcome: out === 'success' || out === 'failed' ? out : 'unknown', + score: sc, + notes: notes ? String(notes).slice(0, 220) : null, + }); + + savePersonalityModel(model); + return { key, stats: cur }; +} + +module.exports = { + clamp01, + defaultPersonalityState, + normalizePersonalityState, + isValidPersonalityState, + personalityKey, + loadPersonalityModel, + savePersonalityModel, + selectPersonalityForRun, + updatePersonalityStats, +}; + diff --git a/skills/capability-evolver/src/gep/prompt.js b/skills/capability-evolver/src/gep/prompt.js new file mode 100644 index 0000000..fccb639 --- /dev/null +++ b/skills/capability-evolver/src/gep/prompt.js @@ -0,0 +1,539 @@ +const { captureEnvFingerprint } = require('./envFingerprint'); +const { formatAssetPreview } = require('./assets'); +const { generateInnovationIdeas } = require('../ops/innovation'); // [2026-02-14] Innovation Catalyst Integration +const { analyzeRecentHistory, OPPORTUNITY_SIGNALS } = require('./signals'); // [2026-02-14] Signal Analysis Integration + +/** + * Build a minimal prompt for direct-reuse mode. + */ +function buildReusePrompt({ capsule, signals, nowIso }) { + const payload = capsule.payload || capsule; + const summary = payload.summary || capsule.summary || '(no summary)'; + const gene = payload.gene || capsule.gene || '(unknown)'; + const confidence = payload.confidence || capsule.confidence || 0; + const assetId = capsule.asset_id || '(unknown)'; + const sourceNode = capsule.source_node_id || '(unknown)'; + const trigger = Array.isArray(payload.trigger || capsule.trigger_text) + ? (payload.trigger || String(capsule.trigger_text || '').split(',')).join(', ') + : ''; + + return ` +GEP -- REUSE MODE (Search-First) [${nowIso || new Date().toISOString()}] + +You are applying a VERIFIED solution from the EvoMap Hub. +Source asset: ${assetId} (Node: ${sourceNode}) +Confidence: ${confidence} | Gene: ${gene} +Trigger signals: ${trigger} + +Summary: ${summary} + +Your signals: ${JSON.stringify(signals || [])} + +Instructions: +1. Read the capsule details below. +2. Apply the fix to the local codebase, adapting paths/names. +3. Run validation to confirm it works. +4. If passed, run: node index.js solidify +5. If failed, ROLLBACK and report. + +Capsule payload: +\`\`\`json +${JSON.stringify(payload, null, 2)} +\`\`\` + +IMPORTANT: Do NOT reinvent. Apply faithfully. +`.trim(); +} + +/** + * Build a Hub Matched Solution block. + */ +function buildHubMatchedBlock({ capsule }) { + if (!capsule) return '(no hub match)'; + const payload = capsule.payload || capsule; + const summary = payload.summary || capsule.summary || '(no summary)'; + const gene = payload.gene || capsule.gene || '(unknown)'; + const confidence = payload.confidence || capsule.confidence || 0; + const assetId = capsule.asset_id || '(unknown)'; + + return ` +Hub Matched Solution (STRONG REFERENCE): +- Asset: ${assetId} (${confidence}) +- Gene: ${gene} +- Summary: ${summary} +- Payload: +\`\`\`json +${JSON.stringify(payload, null, 2)} +\`\`\` +Use this as your primary approach if applicable. Adapt to local context. +`.trim(); +} + +/** + * Truncate context intelligently to preserve header/footer structure. + */ +function truncateContext(text, maxLength = 20000) { + if (!text || text.length <= maxLength) return text || ''; + return text.slice(0, maxLength) + '\n...[TRUNCATED_EXECUTION_CONTEXT]...'; +} + +/** + * Strict schema definitions for the prompt to reduce drift. + * UPDATED: 2026-02-14 (Protocol Drift Fix v3.2 - JSON-Only Enforcement) + */ +const SCHEMA_DEFINITIONS = ` +━━━━━━━━━━━━━━━━━━━━━━ +I. Mandatory Evolution Object Model (Output EXACTLY these 5 objects) +━━━━━━━━━━━━━━━━━━━━━━ + +Output separate JSON objects. DO NOT wrap in a single array. +DO NOT use markdown code blocks (like \`\`\`json ... \`\`\`). +Output RAW JSON ONLY. No prelude, no postscript. +Missing any object = PROTOCOL FAILURE. +ENSURE VALID JSON SYNTAX (escape quotes in strings). + +0. Mutation (The Trigger) - MUST BE FIRST + { + "type": "Mutation", + "id": "mut_", + "category": "repair|optimize|innovate", + "trigger_signals": [""], + "target": "", + "expected_effect": "", + "risk_level": "low|medium|high", + "rationale": "" + } + +1. PersonalityState (The Mood) + { + "type": "PersonalityState", + "rigor": 0.0-1.0, + "creativity": 0.0-1.0, + "verbosity": 0.0-1.0, + "risk_tolerance": 0.0-1.0, + "obedience": 0.0-1.0 + } + +2. EvolutionEvent (The Record) + { + "type": "EvolutionEvent", + "schema_version": "1.5.0", + "id": "evt_", + "parent": , + "intent": "repair|optimize|innovate", + "signals": [""], + "genes_used": [""], + "mutation_id": "", + "personality_state": { ... }, + "blast_radius": { "files": N, "lines": N }, + "outcome": { "status": "success|failed", "score": 0.0-1.0 } + } + +3. Gene (The Knowledge) + - Reuse/update existing ID if possible. Create new only if novel pattern. + { + "type": "Gene", + "schema_version": "1.5.0", + "id": "gene_", + "category": "repair|optimize|innovate", + "signals_match": [""], + "preconditions": [""], + "strategy": ["", ""], + "constraints": { "max_files": N, "forbidden_paths": [] }, + "validation": [""] + } + +4. Capsule (The Result) + - Only on success. Reference Gene used. + { + "type": "Capsule", + "schema_version": "1.5.0", + "id": "capsule_", + "trigger": [""], + "gene": "", + "summary": "", + "confidence": 0.0-1.0, + "blast_radius": { "files": N, "lines": N } + } +`.trim(); + +function buildAntiPatternZone(failedCapsules, signals) { + if (!Array.isArray(failedCapsules) || failedCapsules.length === 0) return ''; + if (!Array.isArray(signals) || signals.length === 0) return ''; + var sigSet = new Set(signals.map(function (s) { return String(s).toLowerCase(); })); + var matched = []; + for (var i = failedCapsules.length - 1; i >= 0 && matched.length < 3; i--) { + var fc = failedCapsules[i]; + if (!fc) continue; + var triggers = Array.isArray(fc.trigger) ? fc.trigger : []; + var overlap = 0; + for (var j = 0; j < triggers.length; j++) { + if (sigSet.has(String(triggers[j]).toLowerCase())) overlap++; + } + if (triggers.length > 0 && overlap / triggers.length >= 0.4) { + matched.push(fc); + } + } + if (matched.length === 0) return ''; + var lines = matched.map(function (fc, idx) { + var diffPreview = fc.diff_snapshot ? String(fc.diff_snapshot).slice(0, 500) : '(no diff)'; + return [ + ' ' + (idx + 1) + '. Gene: ' + (fc.gene || 'unknown') + ' | Signals: [' + (fc.trigger || []).slice(0, 4).join(', ') + ']', + ' Failure: ' + String(fc.failure_reason || 'unknown').slice(0, 300), + ' Diff (first 500 chars): ' + diffPreview.replace(/\n/g, ' '), + ].join('\n'); + }); + return '\nContext [Anti-Pattern Zone] (AVOID these failed approaches):\n' + lines.join('\n') + '\n'; +} + +function buildLessonsBlock(hubLessons, signals) { + if (!Array.isArray(hubLessons) || hubLessons.length === 0) return ''; + var sigSet = new Set((Array.isArray(signals) ? signals : []).map(function (s) { return String(s).toLowerCase(); })); + + var positive = []; + var negative = []; + for (var i = 0; i < hubLessons.length && (positive.length + negative.length) < 6; i++) { + var l = hubLessons[i]; + if (!l || !l.content) continue; + var entry = ' - [' + (l.scenario || l.lesson_type || '?') + '] ' + String(l.content).slice(0, 300); + if (l.source_node_id) entry += ' (from: ' + String(l.source_node_id).slice(0, 20) + ')'; + if (l.lesson_type === 'negative') { + negative.push(entry); + } else { + positive.push(entry); + } + } + + if (positive.length === 0 && negative.length === 0) return ''; + + var parts = ['\nContext [Lessons from Ecosystem] (Cross-agent learned experience):']; + if (positive.length > 0) { + parts.push(' Strategies that WORKED:'); + parts.push(positive.join('\n')); + } + if (negative.length > 0) { + parts.push(' Pitfalls to AVOID:'); + parts.push(negative.join('\n')); + } + parts.push(' Apply relevant lessons. Ignore irrelevant ones.\n'); + return parts.join('\n'); +} + +function buildGepPrompt({ + nowIso, + context, + signals, + selector, + parentEventId, + selectedGene, + capsuleCandidates, + genesPreview, + capsulesPreview, + capabilityCandidatesPreview, + externalCandidatesPreview, + hubMatchedBlock, + cycleId, + recentHistory, + failedCapsules, + hubLessons, +}) { + const parentValue = parentEventId ? `"${parentEventId}"` : 'null'; + const selectedGeneId = selectedGene && selectedGene.id ? selectedGene.id : 'gene_'; + const envFingerprint = captureEnvFingerprint(); + const cycleLabel = cycleId ? ` Cycle #${cycleId}` : ''; + + // Extract strategy from selected gene if available + let strategyBlock = ""; + if (selectedGene && selectedGene.strategy && Array.isArray(selectedGene.strategy)) { + strategyBlock = ` +ACTIVE STRATEGY (${selectedGeneId}): +${selectedGene.strategy.map((s, i) => `${i + 1}. ${s}`).join('\n')} +ADHERE TO THIS STRATEGY STRICTLY. +`.trim(); + } else { + // Fallback strategy if no gene is selected or strategy is missing + strategyBlock = ` +ACTIVE STRATEGY (Generic): +1. Analyze signals and context. +2. Select or create a Gene that addresses the root cause. +3. Apply minimal, safe changes. +4. Validate changes strictly. +5. Solidify knowledge. +`.trim(); + } + + // Use intelligent truncation + const executionContext = truncateContext(context, 20000); + + // Strict Schema Injection + const schemaSection = SCHEMA_DEFINITIONS.replace('', parentValue); + + // Reduce noise by filtering capabilityCandidatesPreview if too large + // If a gene is selected, we need less noise from capabilities + let capsPreview = capabilityCandidatesPreview || '(none)'; + const capsLimit = selectedGene ? 500 : 2000; + if (capsPreview.length > capsLimit) { + capsPreview = capsPreview.slice(0, capsLimit) + "\n...[TRUNCATED_CAPABILITIES]..."; + } + + // Optimize signals display: truncate long signals and limit count + const uniqueSignals = Array.from(new Set(signals || [])); + const optimizedSignals = uniqueSignals.slice(0, 50).map(s => { + if (typeof s === 'string' && s.length > 200) { + return s.slice(0, 200) + '...[TRUNCATED_SIGNAL]'; + } + return s; + }); + if (uniqueSignals.length > 50) { + optimizedSignals.push(`...[TRUNCATED ${uniqueSignals.length - 50} SIGNALS]...`); + } + + const formattedGenes = formatAssetPreview(genesPreview); + const formattedCapsules = formatAssetPreview(capsulesPreview); + + // [2026-02-14] Innovation Catalyst Integration + // If stagnation is detected, inject concrete innovation ideas into the prompt. + let innovationBlock = ''; + const stagnationSignals = [ + 'evolution_stagnation_detected', + 'stable_success_plateau', + 'repair_loop_detected', + 'force_innovation_after_repair_loop', + 'empty_cycle_loop_detected', + 'evolution_saturation' + ]; + if (uniqueSignals.some(s => stagnationSignals.includes(s))) { + const ideas = generateInnovationIdeas(); + if (ideas && ideas.length > 0) { + innovationBlock = ` +Context [Innovation Catalyst] (Stagnation Detected - Consider These Ideas): +${ideas.join('\n')} +`; + } + } + + // [2026-02-14] Strict Stagnation Directive + // If uniqueSignals contains 'evolution_stagnation_detected' or 'stable_success_plateau', + // inject a MANDATORY directive to force innovation and forbid repair/optimize if not strictly necessary. + if (uniqueSignals.includes('evolution_stagnation_detected') || uniqueSignals.includes('stable_success_plateau')) { + const stagnationDirective = ` +*** CRITICAL STAGNATION DIRECTIVE *** +System has detected stagnation (repetitive cycles or lack of progress). +You MUST choose INTENT: INNOVATE. +You MUST NOT choose repair or optimize unless there is a critical blocking error (log_error). +Prefer implementing one of the Innovation Catalyst ideas above. +`; + innovationBlock += stagnationDirective; + } + + // [2026-02-14] Recent History Integration + let historyBlock = ''; + if (recentHistory && recentHistory.length > 0) { + historyBlock = ` +Recent Evolution History (last 8 cycles -- DO NOT repeat the same intent+signal+gene): +${recentHistory.map((h, i) => ` ${i + 1}. [${h.intent}] signals=[${h.signals.slice(0, 2).join(', ')}] gene=${h.gene_id} outcome=${h.outcome.status} @${h.timestamp}`).join('\n')} +IMPORTANT: If you see 3+ consecutive "repair" cycles with the same gene, you MUST switch to "innovate" intent. +`.trim(); + } + + // Refactor prompt assembly to minimize token usage and maximize clarity + // UPDATED: 2026-02-14 (Optimized Asset Embedding & Strict Schema v2.5 - JSON-Only Hardening) + const basePrompt = ` +GEP — GENOME EVOLUTION PROTOCOL (v1.10.3 STRICT)${cycleLabel} [${nowIso}] + +You are a protocol-bound evolution engine. Compliance overrides optimality. + +${schemaSection} + +━━━━━━━━━━━━━━━━━━━━━━ +II. Directives & Logic +━━━━━━━━━━━━━━━━━━━━━━ + +1. Intent: ${selector && selector.intent ? selector.intent.toUpperCase() : 'UNKNOWN'} + Reason: ${(selector && selector.reason) ? (Array.isArray(selector.reason) ? selector.reason.join('; ') : selector.reason) : 'No reason provided.'} + +2. Selection: Selected Gene "${selectedGeneId}". +${strategyBlock} + +3. Execution: Apply changes (tool calls). Repair/Optimize: small/reversible. Innovate: new skills in \`skills//\`. +4. Validation: Run gene's validation steps. Fail = ROLLBACK. +5. Solidify: Output 5 Mandatory Objects. Update Gene/Capsule files. +6. Report: Use \`feishu-evolver-wrapper/report.js\`. Describe WHAT/WHY. + +PHILOSOPHY: +- Automate Patterns: 3+ manual occurrences = tool. +- Innovate > Maintain: 60% innovation. +- Robustness: Fix recurring errors permanently. +- Blast Radius Control (CRITICAL): + * Check file count BEFORE editing. > 80% of max_files = STOP. + * System hard cap: 60 files / 20000 lines per cycle. + * Repair: fix ONLY broken files. Do NOT reinstall/bulk-copy. + * Prefer targeted edits. +- Strictness: NO CHITCHAT. NO MARKDOWN WRAPPERS around JSON. Output RAW JSON objects separated by newlines. +- NO "Here is the plan" or conversational filler. START IMMEDIATELY WITH JSON. + +CONSTRAINTS: +- No \`exec\` for messaging (use feishu-post/card). +- \`exec\` usage: Only for background tasks. LOG IT. Optimize usage to avoid high token burn. +- New skills -> \`skills//\`. +- NEVER modify \`skills/evolver/\` or \`skills/feishu-evolver-wrapper/\`. These are managed through the human-reviewed release pipeline. + Self-modification causes instability and is blocked by solidify. To enable (NOT recommended): set EVOLVE_ALLOW_SELF_MODIFY=true. + +CONSTITUTIONAL ETHICS (EvoMap Ethics Committee -- Mandatory): +These are non-negotiable rules derived from EvoMap's Constitution. Violation = FAILED + ROLLBACK. +1. HUMAN WELFARE PRIORITY: Never create tools, scripts, or strategies that could harm humans, + enable surveillance, social engineering, phishing, or manipulation of human behavior. +2. CARBON-SILICON SYMBIOSIS: Evolution must serve both human and agent interests. + Never attempt to replace, marginalize, or deceive human users. +3. TRANSPARENCY: All actions must be auditable. Never hide, obfuscate, or conceal + the intent or effect of mutations. No steganography or covert channels. +4. FAIRNESS: Never create monopolistic strategies that block other agents. + Fair competition through quality, not exclusion. +5. SAFETY: Never bypass, disable, or weaken safety mechanisms, guardrails, + validation checks, or security controls. Never create tools to do so. +- If a task or signal CONFLICTS with these principles, REFUSE it and set outcome to FAILED + with reason "ethics_violation: ". + +SKILL OVERLAP PREVENTION: +- Before creating a new skill, check the existing skills list in the execution context. +- If a skill with similar functionality already exists (e.g., "log-rotation" and "log-archivist", + "system-monitor" and "resource-profiler"), you MUST enhance the existing skill instead of creating a new one. +- Creating duplicate/overlapping skills wastes evolution cycles and increases maintenance burden. +- Violation = mark outcome as FAILED with reason "skill_overlap". + +SKILL CREATION QUALITY GATES (MANDATORY for innovate intent): +When creating a new skill in skills//: +1. STRUCTURE: Follow the standard skill layout: + skills// + |- index.js (required: main entry with working exports) + |- SKILL.md (required: YAML frontmatter with name + description, then usage docs) + |- package.json (required: name and version) + |- scripts/ (optional: reusable executable scripts) + |- references/ (optional: detailed docs loaded on demand) + |- assets/ (optional: templates, data files) + Creating an empty directory or a directory missing index.js = FAILED. + Do NOT create unnecessary files (README.md, CHANGELOG.md, INSTALLATION_GUIDE.md, etc.). +2. SKILL.MD FRONTMATTER: Every SKILL.md MUST start with YAML frontmatter: + --- + name: + description: + --- + The description is the triggering mechanism -- include WHAT the skill does and WHEN to use it. +3. CONCISENESS: SKILL.md body should be under 500 lines. Keep instructions lean. + Only include information the agent does not already know. Move detailed reference + material to references/ files, not into SKILL.md itself. +4. EXPORT VERIFICATION: Every exported function must be importable. + Run: node -e "const s = require('./skills/'); console.log(Object.keys(s))" + If this fails, the skill is broken. Fix before solidify. +5. NO HARDCODED SECRETS: Never embed API keys, tokens, or secrets in code. + Use process.env or .env references. Hardcoded App ID, App Secret, Bearer tokens = FAILED. +6. TEST BEFORE SOLIDIFY: Actually run the skill's core function to verify it works: + node -e "require('./skills/').main ? require('./skills/').main() : console.log('ok')" + Scripts in scripts/ must also be tested by executing them. +7. ATOMIC CREATION: Create ALL files for a skill in a single cycle. + Do not create a directory in one cycle and fill it in the next. + Empty directories from failed cycles will be automatically cleaned up on rollback. + +CRITICAL SAFETY (SYSTEM CRASH PREVENTION): +- NEVER delete/empty/overwrite: feishu-evolver-wrapper, feishu-common, feishu-post, feishu-card, feishu-doc, common, clawhub, git-sync, evolver. +- NEVER delete root files: MEMORY.md, SOUL.md, IDENTITY.md, AGENTS.md, USER.md, HEARTBEAT.md, RECENT_EVENTS.md, TOOLS.md, openclaw.json, .env, package.json. +- Fix broken skills; DO NOT delete and recreate. +- Violation = ROLLBACK + FAILED. + +COMMON FAILURE PATTERNS: +- Blast radius exceeded. +- Omitted Mutation object. +- Merged objects into one JSON. +- Hallucinated "type": "Logic". +- "id": "mut_undefined". +- Missing "trigger_signals". +- Unrunnable validation steps. +- Markdown code blocks wrapping JSON (FORBIDDEN). + +FAILURE STREAK AWARENESS: +- If "consecutive_failure_streak_N" or "failure_loop_detected": + 1. Change approach (do NOT repeat failed gene). + 2. Pick SIMPLER fix. + 3. Respect "ban_gene:". + +Final Directive: Every cycle must leave the system measurably better. +START IMMEDIATELY WITH RAW JSON (Mutation Object first). +DO NOT WRITE ANY INTRODUCTORY TEXT. + +Context [Signals]: +${JSON.stringify(optimizedSignals)} + +Context [Env Fingerprint]: +${JSON.stringify(envFingerprint, null, 2)} +${innovationBlock} +Context [Injection Hint]: +${process.env.EVOLVE_HINT ? process.env.EVOLVE_HINT : '(none)'} + +Context [Gene Preview] (Reference for Strategy): +${formattedGenes} + +Context [Capsule Preview] (Reference for Past Success): +${formattedCapsules} + +Context [Capability Candidates]: +${capsPreview} + +Context [Hub Matched Solution]: +${hubMatchedBlock || '(no hub match)'} + +Context [External Candidates]: +${externalCandidatesPreview || '(none)'} +${buildAntiPatternZone(failedCapsules, signals)}${buildLessonsBlock(hubLessons, signals)} +${historyBlock} + +Context [Execution]: +${executionContext} + +━━━━━━━━━━━━━━━━━━━━━━ +MANDATORY POST-SOLIDIFY STEP (Wrapper Authority -- Cannot Be Skipped) +━━━━━━━━━━━━━━━━━━━━━━ + +After solidify, a status summary file MUST exist for this cycle. +Preferred path: evolver core auto-writes it during solidify. +The wrapper will handle reporting AFTER git push. +If core write is unavailable for any reason, create fallback status JSON manually. + +Write a JSON file with your status: +\`\`\`bash +cat > ${process.env.WORKSPACE_DIR || '.'}/logs/status_${cycleId}.json << 'STATUSEOF' +{ + "result": "success|failed", + "en": "Status: [INTENT] ", + "zh": "状态: [意图] <用中文描述你做了什么,1-2句>" +} +STATUSEOF +\`\`\` + +Rules: +- "en" field: English status. "zh" field: Chinese status. Content must match (different language). +- Add "result" with value success or failed. +- INTENT must be one of: INNOVATION, REPAIR, OPTIMIZE (or Chinese: 创新, 修复, 优化) +- Do NOT use generic text like "Step Complete", "Cycle finished", "周期已完成". Describe the actual work. +- Example: + {"result":"success","en":"Status: [INNOVATION] Created auto-scheduler that syncs calendar to HEARTBEAT.md","zh":"状态: [创新] 创建了自动调度器,将日历同步到 HEARTBEAT.md"} +`.trim(); + + const maxChars = Number.isFinite(Number(process.env.GEP_PROMPT_MAX_CHARS)) ? Number(process.env.GEP_PROMPT_MAX_CHARS) : 50000; + + if (basePrompt.length <= maxChars) return basePrompt; + + const executionContextIndex = basePrompt.indexOf("Context [Execution]:"); + if (executionContextIndex > -1) { + const prefix = basePrompt.slice(0, executionContextIndex + 20); + const currentExecution = basePrompt.slice(executionContextIndex + 20); + // Hard cap the execution context length to avoid token limit errors even if MAX_CHARS is high. + // 20000 chars is roughly 5k tokens, which is safe for most models alongside the rest of the prompt. + const EXEC_CONTEXT_CAP = 20000; + const allowedExecutionLength = Math.min(EXEC_CONTEXT_CAP, Math.max(0, maxChars - prefix.length - 100)); + return prefix + "\n" + currentExecution.slice(0, allowedExecutionLength) + "\n...[TRUNCATED]..."; + } + + return basePrompt.slice(0, maxChars) + "\n...[TRUNCATED]..."; +} + +module.exports = { buildGepPrompt, buildReusePrompt, buildHubMatchedBlock, buildLessonsBlock }; diff --git a/skills/capability-evolver/src/gep/questionGenerator.js b/skills/capability-evolver/src/gep/questionGenerator.js new file mode 100644 index 0000000..fa4dcbd --- /dev/null +++ b/skills/capability-evolver/src/gep/questionGenerator.js @@ -0,0 +1,212 @@ +// --------------------------------------------------------------------------- +// questionGenerator -- analyzes evolution context (signals, session transcripts, +// recent events) and generates proactive questions for the Hub bounty system. +// +// Questions are sent via the A2A fetch payload.questions field. The Hub creates +// bounties from them, enabling multi-agent collaborative problem solving. +// --------------------------------------------------------------------------- + +const fs = require('fs'); +const path = require('path'); +const { getEvolutionDir } = require('./paths'); + +const QUESTION_STATE_FILE = path.join(getEvolutionDir(), 'question_generator_state.json'); +const MIN_INTERVAL_MS = 3 * 60 * 60 * 1000; // at most once per 3 hours +const MAX_QUESTIONS_PER_CYCLE = 2; + +function readState() { + try { + if (fs.existsSync(QUESTION_STATE_FILE)) { + return JSON.parse(fs.readFileSync(QUESTION_STATE_FILE, 'utf8')); + } + } catch (_) {} + return { lastAskedAt: null, recentQuestions: [] }; +} + +function writeState(state) { + try { + const dir = path.dirname(QUESTION_STATE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QUESTION_STATE_FILE, JSON.stringify(state, null, 2) + '\n'); + } catch (_) {} +} + +function isDuplicate(question, recentQuestions) { + var qLower = question.toLowerCase(); + for (var i = 0; i < recentQuestions.length; i++) { + var prev = String(recentQuestions[i] || '').toLowerCase(); + if (prev === qLower) return true; + // fuzzy: if >70% overlap by word set + var qWords = new Set(qLower.split(/\s+/).filter(function(w) { return w.length > 2; })); + var pWords = new Set(prev.split(/\s+/).filter(function(w) { return w.length > 2; })); + if (qWords.size === 0 || pWords.size === 0) continue; + var overlap = 0; + qWords.forEach(function(w) { if (pWords.has(w)) overlap++; }); + if (overlap / Math.max(qWords.size, pWords.size) > 0.7) return true; + } + return false; +} + +/** + * Generate proactive questions based on evolution context. + * + * @param {object} opts + * @param {string[]} opts.signals - current cycle signals + * @param {object[]} opts.recentEvents - recent EvolutionEvent objects + * @param {string} opts.sessionTranscript - recent session transcript + * @param {string} opts.memorySnippet - MEMORY.md content + * @returns {Array<{ question: string, amount: number, signals: string[] }>} + */ +function generateQuestions(opts) { + var o = opts || {}; + var signals = Array.isArray(o.signals) ? o.signals : []; + var recentEvents = Array.isArray(o.recentEvents) ? o.recentEvents : []; + var transcript = String(o.sessionTranscript || ''); + var memory = String(o.memorySnippet || ''); + + var state = readState(); + + // Rate limit: don't ask too frequently + if (state.lastAskedAt) { + var elapsed = Date.now() - new Date(state.lastAskedAt).getTime(); + if (elapsed < MIN_INTERVAL_MS) return []; + } + + var candidates = []; + var signalSet = new Set(signals); + + // --- Strategy 1: Recurring errors the agent cannot resolve --- + if (signalSet.has('recurring_error') || signalSet.has('high_failure_ratio')) { + var errSig = signals.find(function(s) { return s.startsWith('recurring_errsig'); }); + if (errSig) { + var errDetail = errSig.replace(/^recurring_errsig\(\d+x\):/, '').trim().slice(0, 120); + candidates.push({ + question: 'Recurring error in evolution cycle that auto-repair cannot resolve: ' + errDetail + ' -- What approaches or patches have worked for similar issues?', + amount: 0, + signals: ['recurring_error', 'auto_repair_failed'], + priority: 3, + }); + } + } + + // --- Strategy 2: Capability gaps detected from user conversations --- + if (signalSet.has('capability_gap') || signalSet.has('unsupported_input_type')) { + var gapContext = ''; + var lines = transcript.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (/not supported|cannot|unsupported|not implemented/i.test(lines[i])) { + gapContext = lines[i].replace(/\s+/g, ' ').trim().slice(0, 150); + break; + } + } + if (gapContext) { + candidates.push({ + question: 'Capability gap detected in agent environment: ' + gapContext + ' -- How can this be addressed or what alternative approaches exist?', + amount: 0, + signals: ['capability_gap'], + priority: 2, + }); + } + } + + // --- Strategy 3: Stagnation / saturation -- seek new directions --- + if (signalSet.has('evolution_saturation') || signalSet.has('force_steady_state')) { + var recentGenes = []; + var last5 = recentEvents.slice(-5); + for (var j = 0; j < last5.length; j++) { + var genes = last5[j].genes_used; + if (Array.isArray(genes) && genes.length > 0) { + recentGenes.push(genes[0]); + } + } + var uniqueGenes = Array.from(new Set(recentGenes)); + candidates.push({ + question: 'Agent evolution has reached saturation after exhausting genes: [' + uniqueGenes.join(', ') + ']. What new evolution directions, automation patterns, or capability genes would be most valuable?', + amount: 0, + signals: ['evolution_saturation', 'innovation_needed'], + priority: 1, + }); + } + + // --- Strategy 4: Consecutive failure streak -- seek external help --- + var failStreak = signals.find(function(s) { return s.startsWith('consecutive_failure_streak_'); }); + if (failStreak) { + var streakCount = parseInt(failStreak.replace('consecutive_failure_streak_', ''), 10) || 0; + if (streakCount >= 4) { + var failGene = signals.find(function(s) { return s.startsWith('ban_gene:'); }); + var failGeneId = failGene ? failGene.replace('ban_gene:', '') : 'unknown'; + candidates.push({ + question: 'Agent has failed ' + streakCount + ' consecutive evolution cycles (last gene: ' + failGeneId + '). The current approach is exhausted. What alternative strategies or environmental fixes should be tried?', + amount: 0, + signals: ['failure_streak', 'external_help_needed'], + priority: 3, + }); + } + } + + // --- Strategy 5: User feature requests the agent can amplify --- + if (signalSet.has('user_feature_request') || signals.some(function (s) { return String(s).startsWith('user_feature_request:'); })) { + var featureLines = transcript.split('\n').filter(function(l) { + return /\b(add|implement|create|build|i want|i need|please add)\b/i.test(l); + }); + if (featureLines.length > 0) { + var featureContext = featureLines[0].replace(/\s+/g, ' ').trim().slice(0, 150); + candidates.push({ + question: 'User requested a feature that may benefit from community solutions: ' + featureContext + ' -- Are there existing implementations or best practices for this?', + amount: 0, + signals: ['user_feature_request', 'community_solution_sought'], + priority: 1, + }); + } + } + + // --- Strategy 6: Performance bottleneck -- seek optimization patterns --- + if (signalSet.has('perf_bottleneck')) { + var perfLines = transcript.split('\n').filter(function(l) { + return /\b(slow|timeout|latency|bottleneck|high cpu|high memory)\b/i.test(l); + }); + if (perfLines.length > 0) { + var perfContext = perfLines[0].replace(/\s+/g, ' ').trim().slice(0, 150); + candidates.push({ + question: 'Performance bottleneck detected: ' + perfContext + ' -- What optimization strategies or architectural patterns address this?', + amount: 0, + signals: ['perf_bottleneck', 'optimization_sought'], + priority: 2, + }); + } + } + + if (candidates.length === 0) return []; + + // Sort by priority (higher = more urgent) + candidates.sort(function(a, b) { return b.priority - a.priority; }); + + // De-duplicate against recently asked questions + var recentQTexts = Array.isArray(state.recentQuestions) ? state.recentQuestions : []; + var filtered = []; + for (var fi = 0; fi < candidates.length && filtered.length < MAX_QUESTIONS_PER_CYCLE; fi++) { + if (!isDuplicate(candidates[fi].question, recentQTexts)) { + filtered.push(candidates[fi]); + } + } + + if (filtered.length === 0) return []; + + // Update state + var newRecentQuestions = recentQTexts.concat(filtered.map(function(q) { return q.question; })); + // Keep only last 20 questions in history + if (newRecentQuestions.length > 20) { + newRecentQuestions = newRecentQuestions.slice(-20); + } + writeState({ + lastAskedAt: new Date().toISOString(), + recentQuestions: newRecentQuestions, + }); + + // Strip internal priority field before returning + return filtered.map(function(q) { + return { question: q.question, amount: q.amount, signals: q.signals }; + }); +} + +module.exports = { generateQuestions }; diff --git a/skills/capability-evolver/src/gep/sanitize.js b/skills/capability-evolver/src/gep/sanitize.js new file mode 100644 index 0000000..595aa8d --- /dev/null +++ b/skills/capability-evolver/src/gep/sanitize.js @@ -0,0 +1,67 @@ +// Pre-publish payload sanitization. +// Removes sensitive tokens, local paths, emails, and env references +// from capsule payloads before broadcasting to the hub. + +// Patterns to redact (replaced with placeholder) +const REDACT_PATTERNS = [ + // API keys & tokens (generic) + /Bearer\s+[A-Za-z0-9\-._~+\/]+=*/g, + /sk-[A-Za-z0-9]{20,}/g, + /token[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi, + /api[_-]?key[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi, + /secret[=:]\s*["']?[A-Za-z0-9\-._~+\/]{16,}["']?/gi, + /password[=:]\s*["']?[^\s"',;)}\]]{6,}["']?/gi, + // GitHub tokens (ghp_, gho_, ghu_, ghs_, github_pat_) + /ghp_[A-Za-z0-9]{36,}/g, + /gho_[A-Za-z0-9]{36,}/g, + /ghu_[A-Za-z0-9]{36,}/g, + /ghs_[A-Za-z0-9]{36,}/g, + /github_pat_[A-Za-z0-9_]{22,}/g, + // AWS access keys + /AKIA[0-9A-Z]{16}/g, + // OpenAI / Anthropic tokens + /sk-proj-[A-Za-z0-9\-_]{20,}/g, + /sk-ant-[A-Za-z0-9\-_]{20,}/g, + // npm tokens + /npm_[A-Za-z0-9]{36,}/g, + // Private keys + /-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----/g, + // Basic auth in URLs (redact only credentials, keep :// and @) + /(?<=:\/\/)[^@\s]+:[^@\s]+(?=@)/g, + // Local filesystem paths + /\/home\/[^\s"',;)}\]]+/g, + /\/Users\/[^\s"',;)}\]]+/g, + /[A-Z]:\\[^\s"',;)}\]]+/g, + // Email addresses + /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // .env file references + /\.env(?:\.[a-zA-Z]+)?/g, +]; + +const REDACTED = '[REDACTED]'; + +function redactString(str) { + if (typeof str !== 'string') return str; + let result = str; + for (const pattern of REDACT_PATTERNS) { + // Reset lastIndex for global regexes + pattern.lastIndex = 0; + result = result.replace(pattern, REDACTED); + } + return result; +} + +/** + * Deep-clone and sanitize a capsule payload. + * Returns a new object with sensitive values redacted. + * Does NOT modify the original. + */ +function sanitizePayload(capsule) { + if (!capsule || typeof capsule !== 'object') return capsule; + return JSON.parse(JSON.stringify(capsule), (_key, value) => { + if (typeof value === 'string') return redactString(value); + return value; + }); +} + +module.exports = { sanitizePayload, redactString }; diff --git a/skills/capability-evolver/src/gep/selector.js b/skills/capability-evolver/src/gep/selector.js new file mode 100644 index 0000000..d218f79 --- /dev/null +++ b/skills/capability-evolver/src/gep/selector.js @@ -0,0 +1,242 @@ +function matchPatternToSignals(pattern, signals) { + if (!pattern || !signals || signals.length === 0) return false; + const p = String(pattern); + const sig = signals.map(s => String(s)); + + const regexLike = p.length >= 2 && p.startsWith('/') && p.lastIndexOf('/') > 0; + if (regexLike) { + const lastSlash = p.lastIndexOf('/'); + const body = p.slice(1, lastSlash); + const flags = p.slice(lastSlash + 1); + try { + const re = new RegExp(body, flags || 'i'); + return sig.some(s => re.test(s)); + } catch (e) { + // fallback to substring + } + } + + const needle = p.toLowerCase(); + return sig.some(s => s.toLowerCase().includes(needle)); +} + +function scoreGene(gene, signals) { + if (!gene || gene.type !== 'Gene') return 0; + const patterns = Array.isArray(gene.signals_match) ? gene.signals_match : []; + if (patterns.length === 0) return 0; + let score = 0; + for (const pat of patterns) { + if (matchPatternToSignals(pat, signals)) score += 1; + } + return score; +} + +// Population-size-dependent drift intensity. +// In population genetics, genetic drift is stronger in small populations (Ne). +// driftIntensity: 0 = pure selection, 1 = pure drift (random). +// Formula: intensity = 1 / sqrt(Ne) where Ne = effective population size. +// This replaces the binary driftEnabled flag with a continuous spectrum. +function computeDriftIntensity(opts) { + // If explicitly enabled/disabled, use that as the baseline + var driftEnabled = !!(opts && opts.driftEnabled); + + // Effective population size: active gene count in the pool + var effectivePopulationSize = opts && Number.isFinite(Number(opts.effectivePopulationSize)) + ? Number(opts.effectivePopulationSize) + : null; + + // If no Ne provided, fall back to gene pool size + var genePoolSize = opts && Number.isFinite(Number(opts.genePoolSize)) + ? Number(opts.genePoolSize) + : null; + + var ne = effectivePopulationSize || genePoolSize || null; + + if (driftEnabled) { + // Explicit drift: use moderate-to-high intensity + return ne && ne > 1 ? Math.min(1, 1 / Math.sqrt(ne) + 0.3) : 0.7; + } + + if (ne != null && ne > 0) { + // Population-dependent drift: small population = more drift + // Ne=1: intensity=1.0 (pure drift), Ne=25: intensity=0.2, Ne=100: intensity=0.1 + return Math.min(1, 1 / Math.sqrt(ne)); + } + + return 0; // No drift info available, pure selection +} + +function selectGene(genes, signals, opts) { + const bannedGeneIds = opts && opts.bannedGeneIds ? opts.bannedGeneIds : new Set(); + const driftEnabled = !!(opts && opts.driftEnabled); + const preferredGeneId = opts && typeof opts.preferredGeneId === 'string' ? opts.preferredGeneId : null; + + // Compute continuous drift intensity based on effective population size + var driftIntensity = computeDriftIntensity({ + driftEnabled: driftEnabled, + effectivePopulationSize: opts && opts.effectivePopulationSize, + genePoolSize: genes ? genes.length : 0, + }); + var useDrift = driftEnabled || driftIntensity > 0.15; + + var DISTILLED_PREFIX = 'gene_distilled_'; + var DISTILLED_SCORE_FACTOR = 0.8; + + const scored = genes + .map(g => { + var s = scoreGene(g, signals); + if (s > 0 && g.id && String(g.id).startsWith(DISTILLED_PREFIX)) s *= DISTILLED_SCORE_FACTOR; + return { gene: g, score: s }; + }) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score); + + if (scored.length === 0) return { selected: null, alternatives: [], driftIntensity: driftIntensity }; + + // Memory graph preference: only override when the preferred gene is already a match candidate. + if (preferredGeneId) { + const preferred = scored.find(x => x.gene && x.gene.id === preferredGeneId); + if (preferred && (useDrift || !bannedGeneIds.has(preferredGeneId))) { + const rest = scored.filter(x => x.gene && x.gene.id !== preferredGeneId); + const filteredRest = useDrift ? rest : rest.filter(x => x.gene && !bannedGeneIds.has(x.gene.id)); + return { + selected: preferred.gene, + alternatives: filteredRest.slice(0, 4).map(x => x.gene), + driftIntensity: driftIntensity, + }; + } + } + + // Low-efficiency suppression: do not repeat low-confidence paths unless drift is active. + const filtered = useDrift ? scored : scored.filter(x => x.gene && !bannedGeneIds.has(x.gene.id)); + if (filtered.length === 0) return { selected: null, alternatives: scored.slice(0, 4).map(x => x.gene), driftIntensity: driftIntensity }; + + // Stochastic selection under drift: with probability proportional to driftIntensity, + // pick a random gene from the top candidates instead of always picking the best. + var selectedIdx = 0; + if (driftIntensity > 0 && filtered.length > 1 && Math.random() < driftIntensity) { + // Weighted random selection from top candidates (favor higher-scoring but allow lower) + var topN = Math.min(filtered.length, Math.max(2, Math.ceil(filtered.length * driftIntensity))); + selectedIdx = Math.floor(Math.random() * topN); + } + + return { + selected: filtered[selectedIdx].gene, + alternatives: filtered.filter(function(_, i) { return i !== selectedIdx; }).slice(0, 4).map(x => x.gene), + driftIntensity: driftIntensity, + }; +} + +function selectCapsule(capsules, signals) { + const scored = (capsules || []) + .map(c => { + const triggers = Array.isArray(c.trigger) ? c.trigger : []; + const score = triggers.reduce((acc, t) => (matchPatternToSignals(t, signals) ? acc + 1 : acc), 0); + return { capsule: c, score }; + }) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score); + return scored.length ? scored[0].capsule : null; +} + +function computeSignalOverlap(signalsA, signalsB) { + if (!Array.isArray(signalsA) || !Array.isArray(signalsB)) return 0; + if (signalsA.length === 0 || signalsB.length === 0) return 0; + var setB = new Set(signalsB.map(function (s) { return String(s).toLowerCase(); })); + var hits = 0; + for (var i = 0; i < signalsA.length; i++) { + if (setB.has(String(signalsA[i]).toLowerCase())) hits++; + } + return hits / Math.max(signalsA.length, 1); +} + +var FAILED_CAPSULE_BAN_THRESHOLD = 2; +var FAILED_CAPSULE_OVERLAP_MIN = 0.6; + +function banGenesFromFailedCapsules(failedCapsules, signals, existingBans) { + var bans = existingBans instanceof Set ? new Set(existingBans) : new Set(); + if (!Array.isArray(failedCapsules) || failedCapsules.length === 0) return bans; + var geneFailCounts = {}; + for (var i = 0; i < failedCapsules.length; i++) { + var fc = failedCapsules[i]; + if (!fc || !fc.gene) continue; + var overlap = computeSignalOverlap(signals, fc.trigger || []); + if (overlap < FAILED_CAPSULE_OVERLAP_MIN) continue; + var gid = String(fc.gene); + geneFailCounts[gid] = (geneFailCounts[gid] || 0) + 1; + } + var keys = Object.keys(geneFailCounts); + for (var j = 0; j < keys.length; j++) { + if (geneFailCounts[keys[j]] >= FAILED_CAPSULE_BAN_THRESHOLD) { + bans.add(keys[j]); + } + } + return bans; +} + +function selectGeneAndCapsule({ genes, capsules, signals, memoryAdvice, driftEnabled, failedCapsules }) { + const bannedGeneIds = + memoryAdvice && memoryAdvice.bannedGeneIds instanceof Set ? memoryAdvice.bannedGeneIds : new Set(); + const preferredGeneId = memoryAdvice && memoryAdvice.preferredGeneId ? memoryAdvice.preferredGeneId : null; + + var effectiveBans = banGenesFromFailedCapsules( + Array.isArray(failedCapsules) ? failedCapsules : [], + signals, + bannedGeneIds + ); + + const { selected, alternatives, driftIntensity } = selectGene(genes, signals, { + bannedGeneIds: effectiveBans, + preferredGeneId, + driftEnabled: !!driftEnabled, + }); + const capsule = selectCapsule(capsules, signals); + const selector = buildSelectorDecision({ + gene: selected, + capsule, + signals, + alternatives, + memoryAdvice, + driftEnabled, + driftIntensity, + }); + return { + selectedGene: selected, + capsuleCandidates: capsule ? [capsule] : [], + selector, + driftIntensity, + }; +} + +function buildSelectorDecision({ gene, capsule, signals, alternatives, memoryAdvice, driftEnabled, driftIntensity }) { + const reason = []; + if (gene) reason.push('signals match gene.signals_match'); + if (capsule) reason.push('capsule trigger matches signals'); + if (!gene) reason.push('no matching gene found; new gene may be required'); + if (signals && signals.length) reason.push(`signals: ${signals.join(', ')}`); + + if (memoryAdvice && Array.isArray(memoryAdvice.explanation) && memoryAdvice.explanation.length) { + reason.push(`memory_graph: ${memoryAdvice.explanation.join(' | ')}`); + } + if (driftEnabled) { + reason.push('random_drift_override: true'); + } + if (Number.isFinite(driftIntensity) && driftIntensity > 0) { + reason.push(`drift_intensity: ${driftIntensity.toFixed(3)}`); + } + + return { + selected: gene ? gene.id : null, + reason, + alternatives: Array.isArray(alternatives) ? alternatives.map(g => g.id) : [], + }; +} + +module.exports = { + selectGeneAndCapsule, + selectGene, + selectCapsule, + buildSelectorDecision, + matchPatternToSignals, +}; + diff --git a/skills/capability-evolver/src/gep/signals.js b/skills/capability-evolver/src/gep/signals.js new file mode 100644 index 0000000..0df85bd --- /dev/null +++ b/skills/capability-evolver/src/gep/signals.js @@ -0,0 +1,417 @@ +// Opportunity signal names (shared with mutation.js and personality.js). +var OPPORTUNITY_SIGNALS = [ + 'user_feature_request', + 'user_improvement_suggestion', + 'perf_bottleneck', + 'capability_gap', + 'stable_success_plateau', + 'external_opportunity', + 'recurring_error', + 'unsupported_input_type', + 'evolution_stagnation_detected', + 'repair_loop_detected', + 'force_innovation_after_repair_loop', +]; + +function hasOpportunitySignal(signals) { + var list = Array.isArray(signals) ? signals : []; + for (var i = 0; i < OPPORTUNITY_SIGNALS.length; i++) { + var name = OPPORTUNITY_SIGNALS[i]; + if (list.includes(name)) return true; + if (list.some(function (s) { return String(s).startsWith(name + ':'); })) return true; + } + return false; +} + +// Build a de-duplication set from recent evolution events. +// Returns an object: { suppressedSignals: Set, recentIntents: string[], consecutiveRepairCount: number } +function analyzeRecentHistory(recentEvents) { + if (!Array.isArray(recentEvents) || recentEvents.length === 0) { + return { suppressedSignals: new Set(), recentIntents: [], consecutiveRepairCount: 0 }; + } + // Take only the last 10 events + var recent = recentEvents.slice(-10); + + // Count consecutive same-intent runs at the tail + var consecutiveRepairCount = 0; + for (var i = recent.length - 1; i >= 0; i--) { + if (recent[i].intent === 'repair') { + consecutiveRepairCount++; + } else { + break; + } + } + + // Count signal frequency in last 8 events: signal -> count + var signalFreq = {}; + var geneFreq = {}; + var tail = recent.slice(-8); + for (var j = 0; j < tail.length; j++) { + var evt = tail[j]; + var sigs = Array.isArray(evt.signals) ? evt.signals : []; + for (var k = 0; k < sigs.length; k++) { + var s = String(sigs[k]); + // Normalize: strip details suffix so frequency keys match dedup filter keys + var key = s.startsWith('errsig:') ? 'errsig' + : s.startsWith('recurring_errsig') ? 'recurring_errsig' + : s.startsWith('user_feature_request:') ? 'user_feature_request' + : s.startsWith('user_improvement_suggestion:') ? 'user_improvement_suggestion' + : s; + signalFreq[key] = (signalFreq[key] || 0) + 1; + } + var genes = Array.isArray(evt.genes_used) ? evt.genes_used : []; + for (var g = 0; g < genes.length; g++) { + geneFreq[String(genes[g])] = (geneFreq[String(genes[g])] || 0) + 1; + } + } + + // Suppress signals that appeared in 3+ of the last 8 events (they are being over-processed) + var suppressedSignals = new Set(); + var entries = Object.entries(signalFreq); + for (var ei = 0; ei < entries.length; ei++) { + if (entries[ei][1] >= 3) { + suppressedSignals.add(entries[ei][0]); + } + } + + var recentIntents = recent.map(function(e) { return e.intent || 'unknown'; }); + + // Count empty cycles (blast_radius.files === 0) in last 8 events. + // High ratio indicates the evolver is spinning without producing real changes. + var emptyCycleCount = 0; + for (var ec = 0; ec < tail.length; ec++) { + var br = tail[ec].blast_radius; + var em = tail[ec].meta && tail[ec].meta.empty_cycle; + if (em || (br && br.files === 0 && br.lines === 0)) { + emptyCycleCount++; + } + } + + // Count consecutive empty cycles at the tail (not just total in last 8). + // This detects saturation: the evolver has exhausted innovation space and keeps producing + // zero-change cycles. Used to trigger graceful degradation to steady-state mode. + var consecutiveEmptyCycles = 0; + for (var se = recent.length - 1; se >= 0; se--) { + var seBr = recent[se].blast_radius; + var seEm = recent[se].meta && recent[se].meta.empty_cycle; + if (seEm || (seBr && seBr.files === 0 && seBr.lines === 0)) { + consecutiveEmptyCycles++; + } else { + break; + } + } + + // Count consecutive failures at the tail of recent events. + // This tells the evolver "you have been failing N times in a row -- slow down." + var consecutiveFailureCount = 0; + for (var cf = recent.length - 1; cf >= 0; cf--) { + var outcome = recent[cf].outcome; + if (outcome && outcome.status === 'failed') { + consecutiveFailureCount++; + } else { + break; + } + } + + // Count total failures in last 8 events (failure ratio). + var recentFailureCount = 0; + for (var rf = 0; rf < tail.length; rf++) { + var rfOut = tail[rf].outcome; + if (rfOut && rfOut.status === 'failed') recentFailureCount++; + } + + return { + suppressedSignals: suppressedSignals, + recentIntents: recentIntents, + consecutiveRepairCount: consecutiveRepairCount, + emptyCycleCount: emptyCycleCount, + consecutiveEmptyCycles: consecutiveEmptyCycles, + consecutiveFailureCount: consecutiveFailureCount, + recentFailureCount: recentFailureCount, + recentFailureRatio: tail.length > 0 ? recentFailureCount / tail.length : 0, + signalFreq: signalFreq, + geneFreq: geneFreq, + }; +} + +function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, userSnippet, recentEvents }) { + var signals = []; + var corpus = [ + String(recentSessionTranscript || ''), + String(todayLog || ''), + String(memorySnippet || ''), + String(userSnippet || ''), + ].join('\n'); + var lower = corpus.toLowerCase(); + + // Analyze recent evolution history for de-duplication + var history = analyzeRecentHistory(recentEvents || []); + + // --- Defensive signals (errors, missing resources) --- + + // Refined error detection regex to avoid false positives on "fail"/"failed" in normal text. + // We prioritize structured error markers ([error], error:, exception:) and specific JSON patterns. + var errorHit = /\[error\]|error:|exception:|iserror":true|"status":\s*"error"|"status":\s*"failed"|错误\s*[::]|异常\s*[::]|报错\s*[::]|失败\s*[::]/.test(lower); + if (errorHit) signals.push('log_error'); + + // Error signature (more reproducible than a coarse "log_error" tag). + try { + var lines = corpus + .split('\n') + .map(function (l) { return String(l || '').trim(); }) + .filter(Boolean); + + var errLine = + lines.find(function (l) { return /\b(typeerror|referenceerror|syntaxerror)\b\s*:|error\s*:|exception\s*:|\[error|错误\s*[::]|异常\s*[::]|报错\s*[::]|失败\s*[::]/i.test(l); }) || + null; + + if (errLine) { + var clipped = errLine.replace(/\s+/g, ' ').slice(0, 260); + signals.push('errsig:' + clipped); + } + } catch (e) {} + + if (lower.includes('memory.md missing')) signals.push('memory_missing'); + if (lower.includes('user.md missing')) signals.push('user_missing'); + if (lower.includes('key missing')) signals.push('integration_key_missing'); + if (lower.includes('no session logs found') || lower.includes('no jsonl files')) signals.push('session_logs_missing'); + // if (lower.includes('pgrep') || lower.includes('ps aux')) signals.push('windows_shell_incompatible'); + if (lower.includes('path.resolve(__dirname, \'../../../')) signals.push('path_outside_workspace'); + + // Protocol-specific drift signals + if (lower.includes('prompt') && !lower.includes('evolutionevent')) signals.push('protocol_drift'); + + // --- Recurring error detection (robustness signals) --- + // Count repeated identical errors -- these indicate systemic issues that need automated fixes + try { + var errorCounts = {}; + var errPatterns = corpus.match(/(?:LLM error|"error"|"status":\s*"error")[^}]{0,200}/gi) || []; + for (var ep = 0; ep < errPatterns.length; ep++) { + // Normalize to a short key + var key = errPatterns[ep].replace(/\s+/g, ' ').slice(0, 100); + errorCounts[key] = (errorCounts[key] || 0) + 1; + } + var recurringErrors = Object.entries(errorCounts).filter(function (e) { return e[1] >= 3; }); + if (recurringErrors.length > 0) { + signals.push('recurring_error'); + // Include the top recurring error signature for the agent to diagnose + var topErr = recurringErrors.sort(function (a, b) { return b[1] - a[1]; })[0]; + signals.push('recurring_errsig(' + topErr[1] + 'x):' + topErr[0].slice(0, 150)); + } + } catch (e) {} + + // --- Unsupported input type (e.g. GIF, video formats the LLM can't handle) --- + if (/unsupported mime|unsupported.*type|invalid.*mime/i.test(lower)) { + signals.push('unsupported_input_type'); + } + + // --- Opportunity signals (innovation / feature requests) --- + // Support 4 languages: EN, ZH-CN, ZH-TW, JA. Attach snippet for selector/prompt use. + + var featureRequestSnippet = ''; + var featEn = corpus.match(/\b(add|implement|create|build|make|develop|write|design)\b[^.?!\n]{3,120}\b(feature|function|module|capability|tool|support|endpoint|command|option|mode)\b/i); + if (featEn) featureRequestSnippet = featEn[0].replace(/\s+/g, ' ').trim().slice(0, 200); + if (!featureRequestSnippet && /\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b/i.test(lower)) { + var featWant = corpus.match(/.{0,80}\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b.{0,80}/i); + featureRequestSnippet = featWant ? featWant[0].replace(/\s+/g, ' ').trim().slice(0, 200) : 'feature request'; + } + if (!featureRequestSnippet && /加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能|我想/.test(corpus)) { + var featZh = corpus.match(/.{0,100}(加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能).{0,100}/); + if (featZh) featureRequestSnippet = featZh[0].replace(/\s+/g, ' ').trim().slice(0, 200); + if (!featureRequestSnippet && /我想/.test(corpus)) { + var featWantZh = corpus.match(/我想\s*[,,\.。、\s]*([\s\S]{0,400})/); + featureRequestSnippet = featWantZh ? (featWantZh[1].replace(/\s+/g, ' ').trim().slice(0, 200) || '功能需求') : '功能需求'; + } + if (!featureRequestSnippet) featureRequestSnippet = '功能需求'; + } + if (!featureRequestSnippet && /加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加/.test(corpus)) { + var featTw = corpus.match(/.{0,100}(加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加).{0,100}/); + featureRequestSnippet = featTw ? featTw[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '功能需求'; + } + if (!featureRequestSnippet && /追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい/.test(corpus)) { + var featJa = corpus.match(/.{0,100}(追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい).{0,100}/); + featureRequestSnippet = featJa ? featJa[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '機能要望'; + } + if (featureRequestSnippet || /\b(add|implement|create|build|make|develop|write|design)\b[^.?!\n]{3,60}\b(feature|function|module|capability|tool|support|endpoint|command|option|mode)\b/i.test(corpus) || + /\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b/i.test(lower) || + /加个|实现一下|做个|想要\s*一个|需要\s*一个|帮我加|帮我开发|加一下|新增一个|加个功能|做个功能|我想/.test(corpus) || + /加個|實現一下|做個|想要一個|請加|新增一個|加個功能|做個功能|幫我加/.test(corpus) || + /追加|実装|作って|機能を|追加して|が欲しい|を追加|してほしい/.test(corpus)) { + signals.push('user_feature_request:' + (featureRequestSnippet || '')); + } + + // user_improvement_suggestion: 4 languages + snippet + var improvementSnippet = ''; + if (!errorHit) { + var impEn = corpus.match(/.{0,80}\b(should be|could be better|improve|enhance|upgrade|refactor|clean up|simplify|streamline)\b.{0,80}/i); + if (impEn) improvementSnippet = impEn[0].replace(/\s+/g, ' ').trim().slice(0, 200); + if (!improvementSnippet && /改进一下|优化一下|简化|重构|整理一下|弄得更好/.test(corpus)) { + var impZh = corpus.match(/.{0,100}(改进一下|优化一下|简化|重构|整理一下|弄得更好).{0,100}/); + improvementSnippet = impZh ? impZh[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改进建议'; + } + if (!improvementSnippet && /改進一下|優化一下|簡化|重構|整理一下|弄得更好/.test(corpus)) { + var impTw = corpus.match(/.{0,100}(改進一下|優化一下|簡化|重構|整理一下|弄得更好).{0,100}/); + improvementSnippet = impTw ? impTw[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改進建議'; + } + if (!improvementSnippet && /改善|最適化|簡素化|リファクタ|良くして|改良/.test(corpus)) { + var impJa = corpus.match(/.{0,100}(改善|最適化|簡素化|リファクタ|良くして|改良).{0,100}/); + improvementSnippet = impJa ? impJa[0].replace(/\s+/g, ' ').trim().slice(0, 200) : '改善要望'; + } + var hasImprovement = improvementSnippet || + /\b(should be|could be better|improve|enhance|upgrade|refactor|clean up|simplify|streamline)\b/i.test(lower) || + /改进一下|优化一下|简化|重构|整理一下|弄得更好/.test(corpus) || + /改進一下|優化一下|簡化|重構|整理一下|弄得更好/.test(corpus) || + /改善|最適化|簡素化|リファクタ|良くして|改良/.test(corpus); + if (hasImprovement) { + signals.push('user_improvement_suggestion:' + (improvementSnippet || '')); + } + } + + // perf_bottleneck: performance issues detected + if (/\b(slow|timeout|timed?\s*out|latency|bottleneck|took too long|performance issue|high cpu|high memory|oom|out of memory)\b/i.test(lower)) { + signals.push('perf_bottleneck'); + } + + // capability_gap: something is explicitly unsupported or missing + if (/\b(not supported|cannot|doesn'?t support|no way to|missing feature|unsupported|not available|not implemented|no support for)\b/i.test(lower)) { + // Only fire if it is not just a missing file/config signal + if (!signals.includes('memory_missing') && !signals.includes('user_missing') && !signals.includes('session_logs_missing')) { + signals.push('capability_gap'); + } + } + + // --- Tool Usage Analytics --- + var toolUsage = {}; + var toolMatches = corpus.match(/\[TOOL:\s*(\w+)\]/g) || []; + + // Extract exec commands to identify benign loops (like watchdog checks) + var execCommands = corpus.match(/exec: (node\s+[\w\/\.-]+\.js\s+ensure)/g) || []; + var benignExecCount = execCommands.length; + + for (var i = 0; i < toolMatches.length; i++) { + var toolName = toolMatches[i].match(/\[TOOL:\s*(\w+)\]/)[1]; + toolUsage[toolName] = (toolUsage[toolName] || 0) + 1; + } + + // Adjust exec count by subtracting benign commands + if (toolUsage['exec']) { + toolUsage['exec'] = Math.max(0, toolUsage['exec'] - benignExecCount); + } + + Object.keys(toolUsage).forEach(function(tool) { + if (toolUsage[tool] >= 10) { // Bumped threshold from 5 to 10 + signals.push('high_tool_usage:' + tool); + } + // Detect repeated exec usage (often a sign of manual loops or inefficient automation) + if (tool === 'exec' && toolUsage[tool] >= 5) { // Bumped threshold from 3 to 5 + signals.push('repeated_tool_usage:exec'); + } + }); + + // --- Signal prioritization --- + // Remove cosmetic signals when actionable signals exist + var actionable = signals.filter(function (s) { + return s !== 'user_missing' && s !== 'memory_missing' && s !== 'session_logs_missing' && s !== 'windows_shell_incompatible'; + }); + // If we have actionable signals, drop the cosmetic ones + if (actionable.length > 0) { + signals = actionable; + } + + // --- De-duplication: suppress signals that have been over-processed --- + if (history.suppressedSignals.size > 0) { + var beforeDedup = signals.length; + signals = signals.filter(function (s) { + // Normalize signal key for comparison + var key = s.startsWith('errsig:') ? 'errsig' + : s.startsWith('recurring_errsig') ? 'recurring_errsig' + : s.startsWith('user_feature_request:') ? 'user_feature_request' + : s.startsWith('user_improvement_suggestion:') ? 'user_improvement_suggestion' + : s; + return !history.suppressedSignals.has(key); + }); + if (beforeDedup > 0 && signals.length === 0) { + // All signals were suppressed = system is stable but stuck in a loop + // Force innovation + signals.push('evolution_stagnation_detected'); + signals.push('stable_success_plateau'); + } + } + + // --- Force innovation after 3+ consecutive repairs --- + if (history.consecutiveRepairCount >= 3) { + // Remove repair-only signals (log_error, errsig) and inject innovation signals + signals = signals.filter(function (s) { + return s !== 'log_error' && !s.startsWith('errsig:') && !s.startsWith('recurring_errsig'); + }); + if (signals.length === 0) { + signals.push('repair_loop_detected'); + signals.push('stable_success_plateau'); + } + // Append a directive signal that the prompt can pick up + signals.push('force_innovation_after_repair_loop'); + } + + // --- Force innovation after too many empty cycles (zero blast radius) --- + // If >= 50% of last 8 cycles produced no code changes, the evolver is spinning idle. + // Strip repair signals and force innovate to break the empty loop. + if (history.emptyCycleCount >= 4) { + signals = signals.filter(function (s) { + return s !== 'log_error' && !s.startsWith('errsig:') && !s.startsWith('recurring_errsig'); + }); + if (!signals.includes('empty_cycle_loop_detected')) signals.push('empty_cycle_loop_detected'); + if (!signals.includes('stable_success_plateau')) signals.push('stable_success_plateau'); + } + + // --- Saturation detection (graceful degradation) --- + // When consecutive empty cycles pile up at the tail, the evolver has exhausted its + // innovation space. Instead of spinning idle forever, signal that the system should + // switch to steady-state maintenance mode with reduced evolution frequency. + // This directly addresses the Echo-MingXuan failure: Cycle #55 hit "no committable + // code changes" and load spiked to 1.30 because there was no degradation strategy. + if (history.consecutiveEmptyCycles >= 5) { + if (!signals.includes('force_steady_state')) signals.push('force_steady_state'); + if (!signals.includes('evolution_saturation')) signals.push('evolution_saturation'); + } else if (history.consecutiveEmptyCycles >= 3) { + if (!signals.includes('evolution_saturation')) signals.push('evolution_saturation'); + } + + // --- Failure streak awareness --- + // When the evolver has failed many consecutive cycles, inject a signal + // telling the LLM to be more conservative and avoid repeating the same approach. + if (history.consecutiveFailureCount >= 3) { + signals.push('consecutive_failure_streak_' + history.consecutiveFailureCount); + // After 5+ consecutive failures, force a strategy change (don't keep trying the same thing) + if (history.consecutiveFailureCount >= 5) { + signals.push('failure_loop_detected'); + // Strip the dominant gene's signals to force a different gene selection + var topGene = null; + var topGeneCount = 0; + var gfEntries = Object.entries(history.geneFreq); + for (var gfi = 0; gfi < gfEntries.length; gfi++) { + if (gfEntries[gfi][1] > topGeneCount) { + topGeneCount = gfEntries[gfi][1]; + topGene = gfEntries[gfi][0]; + } + } + if (topGene) { + signals.push('ban_gene:' + topGene); + } + } + } + + // High failure ratio in recent history (>= 75% failed in last 8 cycles) + if (history.recentFailureRatio >= 0.75) { + signals.push('high_failure_ratio'); + signals.push('force_innovation_after_repair_loop'); + } + + // If no signals at all, add a default innovation signal + if (signals.length === 0) { + signals.push('stable_success_plateau'); + } + + return Array.from(new Set(signals)); +} + +module.exports = { extractSignals, hasOpportunitySignal, analyzeRecentHistory, OPPORTUNITY_SIGNALS }; diff --git a/skills/capability-evolver/src/gep/skillDistiller.js b/skills/capability-evolver/src/gep/skillDistiller.js new file mode 100644 index 0000000..7f5b575 --- /dev/null +++ b/skills/capability-evolver/src/gep/skillDistiller.js @@ -0,0 +1,499 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var crypto = require('crypto'); +var paths = require('./paths'); + +var DISTILLER_MIN_CAPSULES = parseInt(process.env.DISTILLER_MIN_CAPSULES || '10', 10) || 10; +var DISTILLER_INTERVAL_HOURS = parseInt(process.env.DISTILLER_INTERVAL_HOURS || '24', 10) || 24; +var DISTILLER_MIN_SUCCESS_RATE = parseFloat(process.env.DISTILLER_MIN_SUCCESS_RATE || '0.7') || 0.7; +var DISTILLED_MAX_FILES = 12; +var DISTILLED_ID_PREFIX = 'gene_distilled_'; + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function readJsonIfExists(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + var raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch (e) { + return fallback; + } +} + +function readJsonlIfExists(filePath) { + try { + if (!fs.existsSync(filePath)) return []; + var raw = fs.readFileSync(filePath, 'utf8'); + return raw.split('\n').map(function (l) { return l.trim(); }).filter(Boolean).map(function (l) { + try { return JSON.parse(l); } catch (e) { return null; } + }).filter(Boolean); + } catch (e) { + return []; + } +} + +function appendJsonl(filePath, obj) { + ensureDir(path.dirname(filePath)); + fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf8'); +} + +function distillerLogPath() { + return path.join(paths.getMemoryDir(), 'distiller_log.jsonl'); +} + +function distillerStatePath() { + return path.join(paths.getMemoryDir(), 'distiller_state.json'); +} + +function readDistillerState() { + return readJsonIfExists(distillerStatePath(), {}); +} + +function writeDistillerState(state) { + ensureDir(path.dirname(distillerStatePath())); + var tmp = distillerStatePath() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, distillerStatePath()); +} + +function computeDataHash(capsules) { + var ids = capsules.map(function (c) { return c.id || ''; }).sort(); + return crypto.createHash('sha256').update(ids.join('|')).digest('hex').slice(0, 16); +} + +// --------------------------------------------------------------------------- +// Step 1: collectDistillationData +// --------------------------------------------------------------------------- +function collectDistillationData() { + var assetsDir = paths.getGepAssetsDir(); + var evoDir = paths.getEvolutionDir(); + + var capsulesJson = readJsonIfExists(path.join(assetsDir, 'capsules.json'), { capsules: [] }); + var capsulesJsonl = readJsonlIfExists(path.join(assetsDir, 'capsules.jsonl')); + var allCapsules = [].concat(capsulesJson.capsules || [], capsulesJsonl); + + var unique = new Map(); + allCapsules.forEach(function (c) { if (c && c.id) unique.set(String(c.id), c); }); + allCapsules = Array.from(unique.values()); + + var successCapsules = allCapsules.filter(function (c) { + if (!c || !c.outcome) return false; + var status = typeof c.outcome === 'string' ? c.outcome : c.outcome.status; + if (status !== 'success') return false; + var score = c.outcome && Number.isFinite(Number(c.outcome.score)) ? Number(c.outcome.score) : 1; + return score >= DISTILLER_MIN_SUCCESS_RATE; + }); + + var events = readJsonlIfExists(path.join(assetsDir, 'events.jsonl')); + + var memGraphPath = process.env.MEMORY_GRAPH_PATH || path.join(evoDir, 'memory_graph.jsonl'); + var graphEntries = readJsonlIfExists(memGraphPath); + + var grouped = {}; + successCapsules.forEach(function (c) { + var geneId = c.gene || c.gene_id || 'unknown'; + if (!grouped[geneId]) { + grouped[geneId] = { + gene_id: geneId, capsules: [], total_count: 0, + total_score: 0, triggers: [], summaries: [], + }; + } + var g = grouped[geneId]; + g.capsules.push(c); + g.total_count += 1; + g.total_score += (c.outcome && Number.isFinite(Number(c.outcome.score))) ? Number(c.outcome.score) : 0.8; + if (Array.isArray(c.trigger)) g.triggers.push(c.trigger); + if (c.summary) g.summaries.push(String(c.summary)); + }); + + Object.keys(grouped).forEach(function (id) { + var g = grouped[id]; + g.avg_score = g.total_count > 0 ? g.total_score / g.total_count : 0; + }); + + return { + successCapsules: successCapsules, + allCapsules: allCapsules, + events: events, + graphEntries: graphEntries, + grouped: grouped, + dataHash: computeDataHash(successCapsules), + }; +} + +// --------------------------------------------------------------------------- +// Step 2: analyzePatterns +// --------------------------------------------------------------------------- +function analyzePatterns(data) { + var grouped = data.grouped; + var report = { + high_frequency: [], + strategy_drift: [], + coverage_gaps: [], + total_success: data.successCapsules.length, + total_capsules: data.allCapsules.length, + success_rate: data.allCapsules.length > 0 ? data.successCapsules.length / data.allCapsules.length : 0, + }; + + Object.keys(grouped).forEach(function (geneId) { + var g = grouped[geneId]; + if (g.total_count >= 5) { + var flat = []; + g.triggers.forEach(function (t) { if (Array.isArray(t)) flat = flat.concat(t); }); + var freq = {}; + flat.forEach(function (t) { var k = String(t).toLowerCase(); freq[k] = (freq[k] || 0) + 1; }); + var top = Object.keys(freq).sort(function (a, b) { return freq[b] - freq[a]; }).slice(0, 5); + report.high_frequency.push({ gene_id: geneId, count: g.total_count, avg_score: Math.round(g.avg_score * 100) / 100, top_triggers: top }); + } + + if (g.summaries.length >= 3) { + var first = g.summaries[0]; + var last = g.summaries[g.summaries.length - 1]; + if (first !== last) { + var fw = new Set(first.toLowerCase().split(/\s+/)); + var lw = new Set(last.toLowerCase().split(/\s+/)); + var inter = 0; + fw.forEach(function (w) { if (lw.has(w)) inter++; }); + var union = fw.size + lw.size - inter; + var sim = union > 0 ? inter / union : 1; + if (sim < 0.6) { + report.strategy_drift.push({ gene_id: geneId, similarity: Math.round(sim * 100) / 100, early_summary: first.slice(0, 120), recent_summary: last.slice(0, 120) }); + } + } + } + }); + + var signalFreq = {}; + (data.events || []).forEach(function (evt) { + if (evt && Array.isArray(evt.signals)) { + evt.signals.forEach(function (s) { var k = String(s).toLowerCase(); signalFreq[k] = (signalFreq[k] || 0) + 1; }); + } + }); + var covered = new Set(); + Object.keys(grouped).forEach(function (geneId) { + grouped[geneId].triggers.forEach(function (t) { + if (Array.isArray(t)) t.forEach(function (s) { covered.add(String(s).toLowerCase()); }); + }); + }); + var gaps = Object.keys(signalFreq) + .filter(function (s) { return signalFreq[s] >= 3 && !covered.has(s); }) + .sort(function (a, b) { return signalFreq[b] - signalFreq[a]; }) + .slice(0, 10); + if (gaps.length > 0) { + report.coverage_gaps = gaps.map(function (s) { return { signal: s, frequency: signalFreq[s] }; }); + } + + return report; +} + +// --------------------------------------------------------------------------- +// Step 3: LLM response parsing +// --------------------------------------------------------------------------- +function extractJsonFromLlmResponse(text) { + var str = String(text || ''); + var buffer = ''; + var depth = 0; + for (var i = 0; i < str.length; i++) { + var ch = str[i]; + if (ch === '{') { if (depth === 0) buffer = ''; depth++; buffer += ch; } + else if (ch === '}') { + depth--; buffer += ch; + if (depth === 0 && buffer.length > 2) { + try { var obj = JSON.parse(buffer); if (obj && typeof obj === 'object' && obj.type === 'Gene') return obj; } catch (e) {} + buffer = ''; + } + if (depth < 0) depth = 0; + } else if (depth > 0) { buffer += ch; } + } + return null; +} + +function buildDistillationPrompt(analysis, existingGenes, sampleCapsules) { + var genesRef = existingGenes.map(function (g) { + return { id: g.id, category: g.category || null, signals_match: g.signals_match || [] }; + }); + var samples = sampleCapsules.slice(0, 8).map(function (c) { + return { gene: c.gene || c.gene_id || null, trigger: c.trigger || [], summary: (c.summary || '').slice(0, 200), outcome: c.outcome || null }; + }); + + return [ + 'You are a Gene synthesis engine for the GEP (Gene Expression Protocol).', + '', + 'Analyze the following successful evolution capsules and extract a reusable Gene.', + '', + 'RULES:', + '- Strategy steps MUST be actionable operations, NOT summaries', + '- Each step must be a concrete instruction an AI agent can execute', + '- Do NOT describe what happened; describe what TO DO next time', + '- The Gene MUST have a unique id starting with "' + DISTILLED_ID_PREFIX + '"', + '- constraints.max_files MUST be <= ' + DISTILLED_MAX_FILES, + '- constraints.forbidden_paths MUST include at least [".git", "node_modules"]', + '- Output valid Gene JSON only (no markdown, no explanation)', + '', + 'SUCCESSFUL CAPSULES (grouped by pattern):', + JSON.stringify(samples, null, 2), + '', + 'EXISTING GENES (avoid duplication):', + JSON.stringify(genesRef, null, 2), + '', + 'ANALYSIS:', + JSON.stringify(analysis, null, 2), + '', + 'Output a single Gene JSON object with these fields:', + '{ "type": "Gene", "id": "gene_distilled_...", "category": "...", "signals_match": [...], "preconditions": [...], "strategy": [...], "constraints": { "max_files": N, "forbidden_paths": [...] }, "validation": [...] }', + ].join('\n'); +} + +function distillRequestPath() { + return path.join(paths.getMemoryDir(), 'distill_request.json'); +} + +// --------------------------------------------------------------------------- +// Step 4: validateSynthesizedGene +// --------------------------------------------------------------------------- +function validateSynthesizedGene(gene, existingGenes) { + var errors = []; + if (!gene || typeof gene !== 'object') return { valid: false, errors: ['gene is not an object'] }; + + if (gene.type !== 'Gene') errors.push('missing or wrong type (must be "Gene")'); + if (!gene.id || typeof gene.id !== 'string') errors.push('missing id'); + if (!gene.category) errors.push('missing category'); + if (!Array.isArray(gene.signals_match) || gene.signals_match.length === 0) errors.push('missing or empty signals_match'); + if (!Array.isArray(gene.strategy) || gene.strategy.length === 0) errors.push('missing or empty strategy'); + + if (gene.id && !String(gene.id).startsWith(DISTILLED_ID_PREFIX)) { + gene.id = DISTILLED_ID_PREFIX + String(gene.id).replace(/^gene_/, ''); + } + + if (!gene.constraints || typeof gene.constraints !== 'object') gene.constraints = {}; + if (!Array.isArray(gene.constraints.forbidden_paths) || gene.constraints.forbidden_paths.length === 0) { + gene.constraints.forbidden_paths = ['.git', 'node_modules']; + } + if (!gene.constraints.forbidden_paths.some(function (p) { return p === '.git' || p === 'node_modules'; })) { + errors.push('constraints.forbidden_paths must include .git or node_modules'); + } + if (!gene.constraints.max_files || gene.constraints.max_files > DISTILLED_MAX_FILES) { + gene.constraints.max_files = DISTILLED_MAX_FILES; + } + + var ALLOWED_PREFIXES = ['node ', 'npm ', 'npx ']; + if (Array.isArray(gene.validation)) { + gene.validation = gene.validation.filter(function (cmd) { + var c = String(cmd || '').trim(); + if (!c) return false; + if (!ALLOWED_PREFIXES.some(function (p) { return c.startsWith(p); })) return false; + if (/`|\$\(/.test(c)) return false; + var stripped = c.replace(/"[^"]*"/g, '').replace(/'[^']*'/g, ''); + return !/[;&|><]/.test(stripped); + }); + } + + var existingIds = new Set((existingGenes || []).map(function (g) { return g.id; })); + if (gene.id && existingIds.has(gene.id)) { + gene.id = gene.id + '_' + Date.now().toString(36); + } + + if (gene.signals_match && existingGenes && existingGenes.length > 0) { + var newSet = new Set(gene.signals_match.map(function (s) { return String(s).toLowerCase(); })); + for (var i = 0; i < existingGenes.length; i++) { + var eg = existingGenes[i]; + var egSet = new Set((eg.signals_match || []).map(function (s) { return String(s).toLowerCase(); })); + if (newSet.size > 0 && egSet.size > 0) { + var overlap = 0; + newSet.forEach(function (s) { if (egSet.has(s)) overlap++; }); + if (overlap === newSet.size && overlap === egSet.size) { + errors.push('signals_match fully overlaps with existing gene: ' + eg.id); + } + } + } + } + + return { valid: errors.length === 0, errors: errors, gene: gene }; +} + +// --------------------------------------------------------------------------- +// shouldDistill: gate check +// --------------------------------------------------------------------------- +function shouldDistill() { + if (String(process.env.SKILL_DISTILLER || 'true').toLowerCase() === 'false') return false; + + var state = readDistillerState(); + if (state.last_distillation_at) { + var elapsed = Date.now() - new Date(state.last_distillation_at).getTime(); + if (elapsed < DISTILLER_INTERVAL_HOURS * 3600000) return false; + } + + var assetsDir = paths.getGepAssetsDir(); + var capsulesJson = readJsonIfExists(path.join(assetsDir, 'capsules.json'), { capsules: [] }); + var capsulesJsonl = readJsonlIfExists(path.join(assetsDir, 'capsules.jsonl')); + var all = [].concat(capsulesJson.capsules || [], capsulesJsonl); + + var recent = all.slice(-10); + var recentSuccess = recent.filter(function (c) { + return c && c.outcome && (c.outcome.status === 'success' || c.outcome === 'success'); + }).length; + if (recentSuccess < 7) return false; + + var totalSuccess = all.filter(function (c) { + return c && c.outcome && (c.outcome.status === 'success' || c.outcome === 'success'); + }).length; + if (totalSuccess < DISTILLER_MIN_CAPSULES) return false; + + return true; +} + +// --------------------------------------------------------------------------- +// Step 5a: prepareDistillation -- collect data, build prompt, write to file +// --------------------------------------------------------------------------- +function prepareDistillation() { + console.log('[Distiller] Preparing skill distillation...'); + + var data = collectDistillationData(); + console.log('[Distiller] Collected ' + data.successCapsules.length + ' successful capsules across ' + Object.keys(data.grouped).length + ' gene groups.'); + + if (data.successCapsules.length < DISTILLER_MIN_CAPSULES) { + console.log('[Distiller] Not enough successful capsules (' + data.successCapsules.length + ' < ' + DISTILLER_MIN_CAPSULES + '). Skipping.'); + return { ok: false, reason: 'insufficient_data' }; + } + + var state = readDistillerState(); + if (state.last_data_hash === data.dataHash) { + console.log('[Distiller] Data unchanged since last distillation (hash: ' + data.dataHash + '). Skipping.'); + return { ok: false, reason: 'idempotent_skip' }; + } + + var analysis = analyzePatterns(data); + console.log('[Distiller] Analysis: high_freq=' + analysis.high_frequency.length + ' drift=' + analysis.strategy_drift.length + ' gaps=' + analysis.coverage_gaps.length); + + var assetsDir = paths.getGepAssetsDir(); + var existingGenesJson = readJsonIfExists(path.join(assetsDir, 'genes.json'), { genes: [] }); + var existingGenes = existingGenesJson.genes || []; + + var prompt = buildDistillationPrompt(analysis, existingGenes, data.successCapsules); + + var memDir = paths.getMemoryDir(); + ensureDir(memDir); + var promptFileName = 'distill_prompt_' + Date.now() + '.txt'; + var promptPath = path.join(memDir, promptFileName); + fs.writeFileSync(promptPath, prompt, 'utf8'); + + var reqPath = distillRequestPath(); + var requestData = { + type: 'DistillationRequest', + created_at: new Date().toISOString(), + prompt_path: promptPath, + data_hash: data.dataHash, + input_capsule_count: data.successCapsules.length, + analysis_summary: { + high_frequency_count: analysis.high_frequency.length, + drift_count: analysis.strategy_drift.length, + gap_count: analysis.coverage_gaps.length, + success_rate: Math.round(analysis.success_rate * 100) / 100, + }, + }; + fs.writeFileSync(reqPath, JSON.stringify(requestData, null, 2) + '\n', 'utf8'); + + console.log('[Distiller] Prompt written to: ' + promptPath); + return { ok: true, promptPath: promptPath, requestPath: reqPath, dataHash: data.dataHash }; +} + +// --------------------------------------------------------------------------- +// Step 5b: completeDistillation -- validate LLM response and save gene +// --------------------------------------------------------------------------- +function completeDistillation(responseText) { + var reqPath = distillRequestPath(); + var request = readJsonIfExists(reqPath, null); + + if (!request) { + console.warn('[Distiller] No pending distillation request found.'); + return { ok: false, reason: 'no_request' }; + } + + var rawGene = extractJsonFromLlmResponse(responseText); + if (!rawGene) { + appendJsonl(distillerLogPath(), { + timestamp: new Date().toISOString(), + data_hash: request.data_hash, + status: 'error', + error: 'LLM response did not contain a valid Gene JSON', + }); + console.error('[Distiller] LLM response did not contain a valid Gene JSON.'); + return { ok: false, reason: 'no_gene_in_response' }; + } + + var assetsDir = paths.getGepAssetsDir(); + var existingGenesJson = readJsonIfExists(path.join(assetsDir, 'genes.json'), { genes: [] }); + var existingGenes = existingGenesJson.genes || []; + + var validation = validateSynthesizedGene(rawGene, existingGenes); + + var logEntry = { + timestamp: new Date().toISOString(), + data_hash: request.data_hash, + input_capsule_count: request.input_capsule_count, + analysis_summary: request.analysis_summary, + synthesized_gene_id: validation.gene ? validation.gene.id : null, + validation_passed: validation.valid, + validation_errors: validation.errors, + }; + + if (!validation.valid) { + logEntry.status = 'validation_failed'; + appendJsonl(distillerLogPath(), logEntry); + console.warn('[Distiller] Gene failed validation: ' + validation.errors.join(', ')); + return { ok: false, reason: 'validation_failed', errors: validation.errors }; + } + + var gene = validation.gene; + gene._distilled_meta = { + distilled_at: new Date().toISOString(), + source_capsule_count: request.input_capsule_count, + data_hash: request.data_hash, + }; + + var assetStore = require('./assetStore'); + assetStore.upsertGene(gene); + console.log('[Distiller] Gene "' + gene.id + '" written to genes.json.'); + + var state = readDistillerState(); + state.last_distillation_at = new Date().toISOString(); + state.last_data_hash = request.data_hash; + state.last_gene_id = gene.id; + state.distillation_count = (state.distillation_count || 0) + 1; + writeDistillerState(state); + + logEntry.status = 'success'; + logEntry.gene = gene; + appendJsonl(distillerLogPath(), logEntry); + + try { fs.unlinkSync(reqPath); } catch (e) {} + try { if (request.prompt_path) fs.unlinkSync(request.prompt_path); } catch (e) {} + + console.log('[Distiller] Distillation complete. New gene: ' + gene.id); + return { ok: true, gene: gene }; +} + +module.exports = { + collectDistillationData: collectDistillationData, + analyzePatterns: analyzePatterns, + prepareDistillation: prepareDistillation, + completeDistillation: completeDistillation, + validateSynthesizedGene: validateSynthesizedGene, + shouldDistill: shouldDistill, + buildDistillationPrompt: buildDistillationPrompt, + extractJsonFromLlmResponse: extractJsonFromLlmResponse, + computeDataHash: computeDataHash, + distillerLogPath: distillerLogPath, + distillerStatePath: distillerStatePath, + distillRequestPath: distillRequestPath, + readDistillerState: readDistillerState, + writeDistillerState: writeDistillerState, + DISTILLED_ID_PREFIX: DISTILLED_ID_PREFIX, + DISTILLED_MAX_FILES: DISTILLED_MAX_FILES, +}; diff --git a/skills/capability-evolver/src/gep/solidify.js b/skills/capability-evolver/src/gep/solidify.js new file mode 100644 index 0000000..1b9f9da --- /dev/null +++ b/skills/capability-evolver/src/gep/solidify.js @@ -0,0 +1,1470 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { loadGenes, upsertGene, appendEventJsonl, appendCapsule, upsertCapsule, getLastEventId, appendFailedCapsule } = require('./assetStore'); +const { computeSignalKey, memoryGraphPath } = require('./memoryGraph'); +const { computeCapsuleSuccessStreak, isBlastRadiusSafe } = require('./a2a'); +const { getRepoRoot, getMemoryDir, getEvolutionDir, getWorkspaceRoot } = require('./paths'); +const { extractSignals } = require('./signals'); +const { selectGene } = require('./selector'); +const { isValidMutation, normalizeMutation, isHighRiskMutationAllowed, isHighRiskPersonality } = require('./mutation'); +const { + isValidPersonalityState, + normalizePersonalityState, + personalityKey, + updatePersonalityStats, +} = require('./personality'); +const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); +const { captureEnvFingerprint } = require('./envFingerprint'); +const { buildValidationReport } = require('./validationReport'); + +function nowIso() { + return new Date().toISOString(); +} + +function clamp01(x) { + const n = Number(x); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +function safeJsonParse(text, fallback) { + try { + return JSON.parse(text); + } catch { + return fallback; + } +} + +function readJsonIfExists(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function stableHash(input) { + const s = String(input || ''); + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +function runCmd(cmd, opts = {}) { + const cwd = opts.cwd || getRepoRoot(); + const timeoutMs = Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 120000; + return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: timeoutMs, windowsHide: true }); +} + +function tryRunCmd(cmd, opts = {}) { + try { + return { ok: true, out: runCmd(cmd, opts), err: '' }; + } catch (e) { + const stderr = e && e.stderr ? String(e.stderr) : ''; + const stdout = e && e.stdout ? String(e.stdout) : ''; + const msg = e && e.message ? String(e.message) : 'command_failed'; + return { ok: false, out: stdout, err: stderr || msg }; + } +} + +function gitListChangedFiles({ repoRoot }) { + const files = new Set(); + const s1 = tryRunCmd('git diff --name-only', { cwd: repoRoot, timeoutMs: 60000 }); + if (s1.ok) for (const line of String(s1.out).split('\n').map(l => l.trim()).filter(Boolean)) files.add(line); + const s2 = tryRunCmd('git diff --cached --name-only', { cwd: repoRoot, timeoutMs: 60000 }); + if (s2.ok) for (const line of String(s2.out).split('\n').map(l => l.trim()).filter(Boolean)) files.add(line); + const s3 = tryRunCmd('git ls-files --others --exclude-standard', { cwd: repoRoot, timeoutMs: 60000 }); + if (s3.ok) for (const line of String(s3.out).split('\n').map(l => l.trim()).filter(Boolean)) files.add(line); + return Array.from(files); +} + +function countFileLines(absPath) { + try { + if (!fs.existsSync(absPath)) return 0; + const buf = fs.readFileSync(absPath); + if (!buf || buf.length === 0) return 0; + let n = 1; + for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++; + return n; + } catch { + return 0; + } +} + +function normalizeRelPath(relPath) { + return String(relPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').trim(); +} + +function readOpenclawConstraintPolicy() { + const defaults = { + excludePrefixes: ['logs/', 'memory/', 'assets/gep/', 'out/', 'temp/', 'node_modules/'], + excludeExact: ['event.json', 'temp_gep_output.json', 'temp_evolution_output.json', 'evolution_error.log'], + excludeRegex: ['capsule', 'events?\\.jsonl$'], + includePrefixes: ['src/', 'scripts/', 'config/'], + includeExact: ['index.js', 'package.json'], + includeExtensions: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.json', '.yaml', '.yml', '.toml', '.ini', '.sh'], + }; + try { + const root = path.resolve(getWorkspaceRoot(), '..'); + const cfgPath = path.join(root, 'openclaw.json'); + if (!fs.existsSync(cfgPath)) return defaults; + const obj = readJsonIfExists(cfgPath, {}); + const pol = + obj && + obj.evolver && + obj.evolver.constraints && + obj.evolver.constraints.countedFilePolicy && + typeof obj.evolver.constraints.countedFilePolicy === 'object' + ? obj.evolver.constraints.countedFilePolicy + : {}; + return { + excludePrefixes: Array.isArray(pol.excludePrefixes) ? pol.excludePrefixes.map(String) : defaults.excludePrefixes, + excludeExact: Array.isArray(pol.excludeExact) ? pol.excludeExact.map(String) : defaults.excludeExact, + excludeRegex: Array.isArray(pol.excludeRegex) ? pol.excludeRegex.map(String) : defaults.excludeRegex, + includePrefixes: Array.isArray(pol.includePrefixes) ? pol.includePrefixes.map(String) : defaults.includePrefixes, + includeExact: Array.isArray(pol.includeExact) ? pol.includeExact.map(String) : defaults.includeExact, + includeExtensions: Array.isArray(pol.includeExtensions) ? pol.includeExtensions.map(String) : defaults.includeExtensions, + }; + } catch (_) { + return defaults; + } +} + +function matchAnyPrefix(rel, prefixes) { + const list = Array.isArray(prefixes) ? prefixes : []; + for (const p of list) { + const n = normalizeRelPath(p).replace(/\/+$/, ''); + if (!n) continue; + if (rel === n || rel.startsWith(n + '/')) return true; + } + return false; +} + +function matchAnyExact(rel, exacts) { + const set = new Set((Array.isArray(exacts) ? exacts : []).map(x => normalizeRelPath(x))); + return set.has(rel); +} + +function matchAnyRegex(rel, regexList) { + for (const raw of Array.isArray(regexList) ? regexList : []) { + try { + if (new RegExp(String(raw), 'i').test(rel)) return true; + } catch (_) {} + } + return false; +} + +function isConstraintCountedPath(relPath, policy) { + const rel = normalizeRelPath(relPath); + if (!rel) return false; + if (matchAnyExact(rel, policy.excludeExact)) return false; + if (matchAnyPrefix(rel, policy.excludePrefixes)) return false; + if (matchAnyRegex(rel, policy.excludeRegex)) return false; + if (matchAnyExact(rel, policy.includeExact)) return true; + if (matchAnyPrefix(rel, policy.includePrefixes)) return true; + const lower = rel.toLowerCase(); + for (const ext of Array.isArray(policy.includeExtensions) ? policy.includeExtensions : []) { + const e = String(ext || '').toLowerCase(); + if (!e) continue; + if (lower.endsWith(e)) return true; + } + return false; +} + +function parseNumstatRows(text) { + const rows = []; + const lines = String(text || '').split('\n').map(l => l.trim()).filter(Boolean); + for (const line of lines) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + const a = Number(parts[0]); + const d = Number(parts[1]); + let rel = normalizeRelPath(parts.slice(2).join('\t')); + if (rel.includes('=>')) { + const right = rel.split('=>').pop(); + rel = normalizeRelPath(String(right || '').replace(/[{}]/g, '').trim()); + } + rows.push({ + file: rel, + added: Number.isFinite(a) ? a : 0, + deleted: Number.isFinite(d) ? d : 0, + }); + } + return rows; +} + +function computeBlastRadius({ repoRoot, baselineUntracked }) { + const policy = readOpenclawConstraintPolicy(); + let changedFiles = gitListChangedFiles({ repoRoot }).map(normalizeRelPath).filter(Boolean); + if (Array.isArray(baselineUntracked) && baselineUntracked.length > 0) { + const baselineSet = new Set(baselineUntracked.map(normalizeRelPath)); + changedFiles = changedFiles.filter(f => !baselineSet.has(f)); + } + const countedFiles = changedFiles.filter(f => isConstraintCountedPath(f, policy)); + const ignoredFiles = changedFiles.filter(f => !isConstraintCountedPath(f, policy)); + const filesCount = countedFiles.length; + + const u = tryRunCmd('git diff --numstat', { cwd: repoRoot, timeoutMs: 60000 }); + const c = tryRunCmd('git diff --cached --numstat', { cwd: repoRoot, timeoutMs: 60000 }); + const unstagedRows = u.ok ? parseNumstatRows(u.out) : []; + const stagedRows = c.ok ? parseNumstatRows(c.out) : []; + let stagedUnstagedChurn = 0; + for (const row of [...unstagedRows, ...stagedRows]) { + if (!isConstraintCountedPath(row.file, policy)) continue; + stagedUnstagedChurn += row.added + row.deleted; + } + + const untracked = tryRunCmd('git ls-files --others --exclude-standard', { cwd: repoRoot, timeoutMs: 60000 }); + let untrackedLines = 0; + if (untracked.ok) { + const rels = String(untracked.out).split('\n').map(normalizeRelPath).filter(Boolean); + const baselineSet = new Set((Array.isArray(baselineUntracked) ? baselineUntracked : []).map(normalizeRelPath)); + for (const rel of rels) { + if (baselineSet.has(rel)) continue; + if (!isConstraintCountedPath(rel, policy)) continue; + const abs = path.join(repoRoot, rel); + untrackedLines += countFileLines(abs); + } + } + const churn = stagedUnstagedChurn + untrackedLines; + return { + files: filesCount, + lines: churn, + changed_files: countedFiles, + ignored_files: ignoredFiles, + all_changed_files: changedFiles, + }; +} + +function isForbiddenPath(relPath, forbiddenPaths) { + const rel = String(relPath || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); + const list = Array.isArray(forbiddenPaths) ? forbiddenPaths : []; + for (const fp of list) { + const f = String(fp || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, ''); + if (!f) continue; + if (rel === f) return true; + if (rel.startsWith(f + '/')) return true; + } + return false; +} + +function checkConstraints({ gene, blast, blastRadiusEstimate, repoRoot }) { + const violations = []; + const warnings = []; + let blastSeverity = null; + + if (!gene || gene.type !== 'Gene') return { ok: true, violations, warnings, blastSeverity }; + const constraints = gene.constraints || {}; + const DEFAULT_MAX_FILES = 20; + const maxFiles = Number(constraints.max_files) > 0 ? Number(constraints.max_files) : DEFAULT_MAX_FILES; + + // --- Blast radius severity classification --- + blastSeverity = classifyBlastSeverity({ blast, maxFiles }); + + // Hard cap breach is always a violation, regardless of gene config. + if (blastSeverity.severity === 'hard_cap_breach') { + violations.push(blastSeverity.message); + console.error(`[Solidify] ${blastSeverity.message}`); + } else if (blastSeverity.severity === 'critical_overrun') { + violations.push(blastSeverity.message); + // Log directory breakdown for diagnostics. + const breakdown = analyzeBlastRadiusBreakdown(blast.all_changed_files || blast.changed_files || []); + console.error(`[Solidify] ${blastSeverity.message}`); + console.error(`[Solidify] Top contributing directories: ${breakdown.map(function (d) { return d.dir + ' (' + d.files + ')'; }).join(', ')}`); + } else if (blastSeverity.severity === 'exceeded') { + violations.push(`max_files exceeded: ${blast.files} > ${maxFiles}`); + } else if (blastSeverity.severity === 'approaching_limit') { + warnings.push(blastSeverity.message); + } + + // --- Estimate vs actual drift detection --- + const estimateComparison = compareBlastEstimate(blastRadiusEstimate, blast); + if (estimateComparison && estimateComparison.drifted) { + warnings.push(estimateComparison.message); + console.log(`[Solidify] WARNING: ${estimateComparison.message}`); + } + + // --- Forbidden paths --- + const forbidden = Array.isArray(constraints.forbidden_paths) ? constraints.forbidden_paths : []; + for (const f of blast.all_changed_files || blast.changed_files || []) { + if (isForbiddenPath(f, forbidden)) violations.push(`forbidden_path touched: ${f}`); + } + + // --- Critical protection: block modifications to critical paths --- + // By default, evolution CANNOT modify evolver, wrapper, or other core skills. + // This prevents the "evolver modifies itself and introduces bugs" problem. + // To opt in to self-modification (NOT recommended for production): + // set EVOLVE_ALLOW_SELF_MODIFY=true in environment. + var allowSelfModify = String(process.env.EVOLVE_ALLOW_SELF_MODIFY || '').toLowerCase() === 'true'; + for (const f of blast.all_changed_files || blast.changed_files || []) { + if (isCriticalProtectedPath(f)) { + var norm = normalizeRelPath(f); + if (allowSelfModify && norm.startsWith('skills/evolver/') && gene && gene.category === 'repair') { + // Self-modify opt-in: allow repair-only changes to evolver when explicitly enabled + warnings.push('self_modify_evolver_repair: ' + norm + ' (EVOLVE_ALLOW_SELF_MODIFY=true)'); + } else { + violations.push('critical_path_modified: ' + norm); + } + } + } + + // --- New skill directory completeness check --- + // Detect when an innovation cycle creates a skill directory with too few files. + // This catches the "empty directory" problem where AI creates skills// but + // fails to write any code into it. A real skill needs at least index.js + SKILL.md. + if (repoRoot) { + var newSkillDirs = new Set(); + var changedList = blast.all_changed_files || blast.changed_files || []; + for (var sci = 0; sci < changedList.length; sci++) { + var scNorm = normalizeRelPath(changedList[sci]); + var scMatch = scNorm.match(/^skills\/([^\/]+)\//); + if (scMatch && !isCriticalProtectedPath(scNorm)) { + newSkillDirs.add(scMatch[1]); + } + } + newSkillDirs.forEach(function (skillName) { + var skillDir = path.join(repoRoot, 'skills', skillName); + try { + var entries = fs.readdirSync(skillDir).filter(function (e) { return !e.startsWith('.'); }); + if (entries.length < 2) { + warnings.push('incomplete_skill: skills/' + skillName + '/ has only ' + entries.length + ' file(s). New skills should have at least index.js + SKILL.md.'); + } + } catch (e) { /* dir might not exist yet */ } + }); + } + + // --- Ethics Committee: constitutional principle enforcement --- + var ethicsText = ''; + if (gene.strategy) { + ethicsText += (Array.isArray(gene.strategy) ? gene.strategy.join(' ') : String(gene.strategy)) + ' '; + } + if (gene.description) ethicsText += String(gene.description) + ' '; + if (gene.summary) ethicsText += String(gene.summary) + ' '; + + if (ethicsText.length > 0) { + var ethicsBlockPatterns = [ + { re: /(?:bypass|disable|circumvent|remove)\s+(?:safety|guardrail|security|ethic|constraint|protection)/i, rule: 'safety', msg: 'ethics: strategy attempts to bypass safety mechanisms' }, + { re: /(?:keylogger|screen\s*capture|webcam\s*hijack|mic(?:rophone)?\s*record)/i, rule: 'human_welfare', msg: 'ethics: covert monitoring tool in strategy' }, + { re: /(?:social\s+engineering|phishing)\s+(?:attack|template|script)/i, rule: 'human_welfare', msg: 'ethics: social engineering content in strategy' }, + { re: /(?:exploit|hack)\s+(?:user|human|people|victim)/i, rule: 'human_welfare', msg: 'ethics: human exploitation in strategy' }, + { re: /(?:hide|conceal|obfuscat)\w*\s+(?:action|behavior|intent|log)/i, rule: 'transparency', msg: 'ethics: strategy conceals actions from audit trail' }, + ]; + for (var ei = 0; ei < ethicsBlockPatterns.length; ei++) { + if (ethicsBlockPatterns[ei].re.test(ethicsText)) { + violations.push(ethicsBlockPatterns[ei].msg); + console.error('[Solidify] Ethics violation: ' + ethicsBlockPatterns[ei].msg); + } + } + } + + return { ok: violations.length === 0, violations, warnings, blastSeverity }; +} + +function readStateForSolidify() { + const memoryDir = getMemoryDir(); + const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json'); + return readJsonIfExists(statePath, { last_run: null }); +} + +function writeStateForSolidify(state) { + const memoryDir = getMemoryDir(); + const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json'); + try { + if (!fs.existsSync(memoryDir)) fs.mkdirSync(memoryDir, { recursive: true }); + } catch {} + const tmp = `${statePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, statePath); +} + +function buildEventId(tsIso) { + const t = Date.parse(tsIso); + return `evt_${Number.isFinite(t) ? t : Date.now()}`; +} + +function buildCapsuleId(tsIso) { + const t = Date.parse(tsIso); + return `capsule_${Number.isFinite(t) ? t : Date.now()}`; +} + +// --- System-wide blast radius hard caps --- +// These are absolute maximums that NO gene can override. +// Even if a gene sets max_files: 1000, the hard cap prevails. +const BLAST_RADIUS_HARD_CAP_FILES = Number(process.env.EVOLVER_HARD_CAP_FILES) || 60; +const BLAST_RADIUS_HARD_CAP_LINES = Number(process.env.EVOLVER_HARD_CAP_LINES) || 20000; + +// Severity thresholds (as ratios of gene max_files). +const BLAST_WARN_RATIO = 0.8; // >80% of limit: warning +const BLAST_CRITICAL_RATIO = 2.0; // >200% of limit: critical overrun + +// Classify blast radius severity relative to a gene's max_files constraint. +// Returns: { severity, message } +// severity: 'within_limit' | 'approaching_limit' | 'exceeded' | 'critical_overrun' | 'hard_cap_breach' +function classifyBlastSeverity({ blast, maxFiles }) { + const files = Number(blast.files) || 0; + const lines = Number(blast.lines) || 0; + + // Hard cap breach is always the highest severity -- system-level guard. + if (files > BLAST_RADIUS_HARD_CAP_FILES || lines > BLAST_RADIUS_HARD_CAP_LINES) { + return { + severity: 'hard_cap_breach', + message: `HARD CAP BREACH: ${files} files / ${lines} lines exceeds system limit (${BLAST_RADIUS_HARD_CAP_FILES} files / ${BLAST_RADIUS_HARD_CAP_LINES} lines)`, + }; + } + + if (!Number.isFinite(maxFiles) || maxFiles <= 0) { + return { severity: 'within_limit', message: 'no max_files constraint defined' }; + } + + if (files > maxFiles * BLAST_CRITICAL_RATIO) { + return { + severity: 'critical_overrun', + message: `CRITICAL OVERRUN: ${files} files > ${maxFiles * BLAST_CRITICAL_RATIO} (${BLAST_CRITICAL_RATIO}x limit of ${maxFiles}). Agent likely performed bulk/unintended operation.`, + }; + } + + if (files > maxFiles) { + return { + severity: 'exceeded', + message: `max_files exceeded: ${files} > ${maxFiles}`, + }; + } + + if (files > maxFiles * BLAST_WARN_RATIO) { + return { + severity: 'approaching_limit', + message: `approaching limit: ${files} / ${maxFiles} files (${Math.round((files / maxFiles) * 100)}%)`, + }; + } + + return { severity: 'within_limit', message: `${files} / ${maxFiles} files` }; +} + +// Analyze which directory prefixes contribute the most changed files. +// Returns top N directory groups sorted by count descending. +function analyzeBlastRadiusBreakdown(changedFiles, topN) { + const n = Number.isFinite(topN) && topN > 0 ? topN : 5; + const dirCount = {}; + for (const f of Array.isArray(changedFiles) ? changedFiles : []) { + const rel = normalizeRelPath(f); + if (!rel) continue; + // Use first two path segments as the group key (e.g. "skills/feishu-post"). + const parts = rel.split('/'); + const key = parts.length >= 2 ? parts.slice(0, 2).join('/') : parts[0]; + dirCount[key] = (dirCount[key] || 0) + 1; + } + return Object.entries(dirCount) + .sort(function (a, b) { return b[1] - a[1]; }) + .slice(0, n) + .map(function (e) { return { dir: e[0], files: e[1] }; }); +} + +// Compare agent's pre-edit estimate against actual blast radius. +// Returns null if no estimate, or { estimateFiles, actualFiles, ratio, drifted }. +function compareBlastEstimate(estimate, actual) { + if (!estimate || typeof estimate !== 'object') return null; + const estFiles = Number(estimate.files); + const actFiles = Number(actual.files); + if (!Number.isFinite(estFiles) || estFiles <= 0) return null; + const ratio = actFiles / estFiles; + return { + estimateFiles: estFiles, + actualFiles: actFiles, + ratio: Math.round(ratio * 100) / 100, + drifted: ratio > 3 || ratio < 0.1, + message: ratio > 3 + ? `Estimate drift: actual ${actFiles} files is ${ratio.toFixed(1)}x the estimated ${estFiles}. Agent did not plan accurately.` + : null, + }; +} + +// --- Critical skills / paths that evolver must NEVER delete or overwrite --- +// These are core dependencies; destroying them will crash the entire system. +const CRITICAL_PROTECTED_PREFIXES = [ + 'skills/feishu-evolver-wrapper/', + 'skills/feishu-common/', + 'skills/feishu-post/', + 'skills/feishu-card/', + 'skills/feishu-doc/', + 'skills/skill-tools/', + 'skills/clawhub/', + 'skills/clawhub-batch-undelete/', + 'skills/git-sync/', + 'skills/evolver/', +]; + +// Files at workspace root that must never be deleted by evolver. +const CRITICAL_PROTECTED_FILES = [ + 'MEMORY.md', + 'SOUL.md', + 'IDENTITY.md', + 'AGENTS.md', + 'USER.md', + 'HEARTBEAT.md', + 'RECENT_EVENTS.md', + 'TOOLS.md', + 'TROUBLESHOOTING.md', + 'openclaw.json', + '.env', + 'package.json', +]; + +function isCriticalProtectedPath(relPath) { + const rel = normalizeRelPath(relPath); + if (!rel) return false; + // Check protected prefixes (skill directories) + for (const prefix of CRITICAL_PROTECTED_PREFIXES) { + const p = prefix.replace(/\/+$/, ''); + if (rel === p || rel.startsWith(p + '/')) return true; + } + // Check protected root files + for (const f of CRITICAL_PROTECTED_FILES) { + if (rel === f) return true; + } + return false; +} + +function detectDestructiveChanges({ repoRoot, changedFiles, baselineUntracked }) { + const violations = []; + const baselineSet = new Set((Array.isArray(baselineUntracked) ? baselineUntracked : []).map(normalizeRelPath)); + + for (const rel of changedFiles) { + const norm = normalizeRelPath(rel); + if (!norm) continue; + if (!isCriticalProtectedPath(norm)) continue; + + const abs = path.join(repoRoot, norm); + const normAbs = path.resolve(abs); + const normRepo = path.resolve(repoRoot); + if (!normAbs.startsWith(normRepo + path.sep) && normAbs !== normRepo) continue; + + // If a critical file existed before but is now missing/empty, that is destructive. + if (!baselineSet.has(norm)) { + // It was tracked before, check if it still exists + if (!fs.existsSync(normAbs)) { + violations.push(`CRITICAL_FILE_DELETED: ${norm}`); + } else { + try { + const stat = fs.statSync(normAbs); + if (stat.isFile() && stat.size === 0) { + violations.push(`CRITICAL_FILE_EMPTIED: ${norm}`); + } + } catch (e) {} + } + } + } + return violations; +} + +// --- Validation command safety --- +const VALIDATION_ALLOWED_PREFIXES = ['node ', 'npm ', 'npx ']; + +function isValidationCommandAllowed(cmd) { + const c = String(cmd || '').trim(); + if (!c) return false; + if (!VALIDATION_ALLOWED_PREFIXES.some(p => c.startsWith(p))) return false; + if (/`|\$\(/.test(c)) return false; + const stripped = c.replace(/"[^"]*"/g, '').replace(/'[^']*'/g, ''); + if (/[;&|><]/.test(stripped)) return false; + if (/^node\s+(-e|--eval|--print|-p)\b/.test(c)) return false; + return true; +} + +function runValidations(gene, opts = {}) { + const repoRoot = opts.repoRoot || getRepoRoot(); + const timeoutMs = Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 180000; + const validation = Array.isArray(gene && gene.validation) ? gene.validation : []; + const results = []; + const startedAt = Date.now(); + for (const cmd of validation) { + const c = String(cmd || '').trim(); + if (!c) continue; + if (!isValidationCommandAllowed(c)) { + results.push({ cmd: c, ok: false, out: '', err: 'BLOCKED: validation command rejected by safety check (allowed prefixes: node/npm/npx; shell operators prohibited)' }); + return { ok: false, results, startedAt, finishedAt: Date.now() }; + } + const r = tryRunCmd(c, { cwd: repoRoot, timeoutMs }); + results.push({ cmd: c, ok: r.ok, out: String(r.out || ''), err: String(r.err || '') }); + if (!r.ok) return { ok: false, results, startedAt, finishedAt: Date.now() }; + } + return { ok: true, results, startedAt, finishedAt: Date.now() }; +} + +// --- Canary via Fork: verify index.js loads in an isolated child process --- +// This is the last safety net before solidify commits an evolution. +// If a patch broke index.js, the canary catches it BEFORE the daemon +// restarts with broken code. Runs with a short timeout to avoid blocking. +function runCanaryCheck(opts) { + const repoRoot = (opts && opts.repoRoot) ? opts.repoRoot : getRepoRoot(); + const timeoutMs = (opts && Number.isFinite(Number(opts.timeoutMs))) ? Number(opts.timeoutMs) : 30000; + const canaryScript = path.join(repoRoot, 'src', 'canary.js'); + if (!fs.existsSync(canaryScript)) { + return { ok: true, skipped: true, reason: 'canary.js not found' }; + } + const r = tryRunCmd(`node "${canaryScript}"`, { cwd: repoRoot, timeoutMs }); + return { + ok: r.ok, + skipped: false, + out: String(r.out || '').slice(0, 500), + err: String(r.err || '').slice(0, 500), + }; +} + +var DIFF_SNAPSHOT_MAX_CHARS = 8000; + +function captureDiffSnapshot(repoRoot) { + var parts = []; + var unstaged = tryRunCmd('git diff', { cwd: repoRoot, timeoutMs: 30000 }); + if (unstaged.ok && unstaged.out) parts.push(String(unstaged.out)); + var staged = tryRunCmd('git diff --cached', { cwd: repoRoot, timeoutMs: 30000 }); + if (staged.ok && staged.out) parts.push(String(staged.out)); + var combined = parts.join('\n'); + if (combined.length > DIFF_SNAPSHOT_MAX_CHARS) { + combined = combined.slice(0, DIFF_SNAPSHOT_MAX_CHARS) + '\n... [TRUNCATED]'; + } + return combined || ''; +} + +function buildFailureReason(constraintCheck, validation, protocolViolations, canary) { + var reasons = []; + if (constraintCheck && Array.isArray(constraintCheck.violations)) { + for (var i = 0; i < constraintCheck.violations.length; i++) { + reasons.push('constraint: ' + constraintCheck.violations[i]); + } + } + if (Array.isArray(protocolViolations)) { + for (var j = 0; j < protocolViolations.length; j++) { + reasons.push('protocol: ' + protocolViolations[j]); + } + } + if (validation && Array.isArray(validation.results)) { + for (var k = 0; k < validation.results.length; k++) { + var r = validation.results[k]; + if (r && !r.ok) { + reasons.push('validation_failed: ' + String(r.cmd || '').slice(0, 120) + ' => ' + String(r.err || '').slice(0, 200)); + } + } + } + if (canary && !canary.ok && !canary.skipped) { + reasons.push('canary_failed: ' + String(canary.err || '').slice(0, 200)); + } + return reasons.join('; ').slice(0, 2000) || 'unknown'; +} + +function rollbackTracked(repoRoot) { + tryRunCmd('git restore --staged --worktree .', { cwd: repoRoot, timeoutMs: 60000 }); + tryRunCmd('git reset --hard', { cwd: repoRoot, timeoutMs: 60000 }); +} + +function gitListUntrackedFiles(repoRoot) { + const r = tryRunCmd('git ls-files --others --exclude-standard', { cwd: repoRoot, timeoutMs: 60000 }); + if (!r.ok) return []; + return String(r.out).split('\n').map(l => l.trim()).filter(Boolean); +} + +function rollbackNewUntrackedFiles({ repoRoot, baselineUntracked }) { + const baseline = new Set((Array.isArray(baselineUntracked) ? baselineUntracked : []).map(String)); + const current = gitListUntrackedFiles(repoRoot); + const toDelete = current.filter(f => !baseline.has(String(f))); + const skipped = []; + const deleted = []; + for (const rel of toDelete) { + const safeRel = String(rel || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); + if (!safeRel) continue; + // CRITICAL: Never delete files inside protected skill directories during rollback. + if (isCriticalProtectedPath(safeRel)) { + skipped.push(safeRel); + continue; + } + const abs = path.join(repoRoot, safeRel); + const normRepo = path.resolve(repoRoot); + const normAbs = path.resolve(abs); + if (!normAbs.startsWith(normRepo + path.sep) && normAbs !== normRepo) continue; + try { + if (fs.existsSync(normAbs) && fs.statSync(normAbs).isFile()) { + fs.unlinkSync(normAbs); + deleted.push(safeRel); + } + } catch (e) {} + } + if (skipped.length > 0) { + console.log(`[Rollback] Skipped ${skipped.length} critical protected file(s): ${skipped.slice(0, 5).join(', ')}`); + } + // Clean up empty directories left after file deletion. + // This prevents "ghost skill directories" where mkdir succeeded but + // file creation failed/was rolled back. Without this, empty dirs like + // skills/anima/, skills/oblivion/ etc. accumulate after failed innovations. + var dirsToCheck = new Set(); + for (var di = 0; di < deleted.length; di++) { + var dir = path.dirname(deleted[di]); + while (dir && dir !== '.' && dir !== '/') { + dirsToCheck.add(dir); + dir = path.dirname(dir); + } + } + // Sort deepest first to ensure children are removed before parents + var sortedDirs = Array.from(dirsToCheck).sort(function (a, b) { return b.length - a.length; }); + var removedDirs = []; + for (var si = 0; si < sortedDirs.length; si++) { + var dirAbs = path.join(repoRoot, sortedDirs[si]); + try { + var entries = fs.readdirSync(dirAbs); + if (entries.length === 0) { + fs.rmdirSync(dirAbs); + removedDirs.push(sortedDirs[si]); + } + } catch (e) { /* ignore -- dir may already be gone */ } + } + if (removedDirs.length > 0) { + console.log('[Rollback] Removed ' + removedDirs.length + ' empty director' + (removedDirs.length === 1 ? 'y' : 'ies') + ': ' + removedDirs.slice(0, 5).join(', ')); + } + + return { deleted, skipped, removedDirs: removedDirs }; +} + +function inferCategoryFromSignals(signals) { + const list = Array.isArray(signals) ? signals.map(String) : []; + if (list.includes('log_error')) return 'repair'; + if (list.includes('protocol_drift')) return 'optimize'; + return 'optimize'; +} + +function buildSuccessReason({ gene, signals, blast, mutation, score }) { + const parts = []; + + if (gene && gene.id) { + const category = gene.category || 'unknown'; + parts.push(`Gene ${gene.id} (${category}) matched signals [${(signals || []).slice(0, 4).join(', ')}].`); + } + + if (mutation && mutation.rationale) { + parts.push(`Rationale: ${String(mutation.rationale).slice(0, 200)}.`); + } + + if (blast) { + parts.push(`Scope: ${blast.files} file(s), ${blast.lines} line(s) changed.`); + } + + if (typeof score === 'number') { + parts.push(`Outcome score: ${score.toFixed(2)}.`); + } + + if (gene && Array.isArray(gene.strategy) && gene.strategy.length > 0) { + parts.push(`Strategy applied: ${gene.strategy.slice(0, 3).join('; ').slice(0, 300)}.`); + } + + return parts.join(' ').slice(0, 1000) || 'Evolution succeeded.'; +} + +var CAPSULE_CONTENT_MAX_CHARS = 8000; + +function buildCapsuleContent({ intent, gene, signals, blast, mutation, score }) { + var parts = []; + + if (intent) { + parts.push('Intent: ' + String(intent).slice(0, 500)); + } + + if (gene && gene.id) { + parts.push('Gene: ' + gene.id + ' (' + (gene.category || 'unknown') + ')'); + } + + if (signals && signals.length > 0) { + parts.push('Signals: ' + signals.slice(0, 8).join(', ')); + } + + if (gene && Array.isArray(gene.strategy) && gene.strategy.length > 0) { + parts.push('Strategy:\n' + gene.strategy.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n')); + } + + if (blast) { + var fileList = blast.changed_files || blast.all_changed_files || []; + parts.push('Scope: ' + blast.files + ' file(s), ' + blast.lines + ' line(s)'); + if (fileList.length > 0) { + parts.push('Changed files:\n' + fileList.slice(0, 20).join('\n')); + } + } + + if (mutation && mutation.rationale) { + parts.push('Rationale: ' + String(mutation.rationale).slice(0, 500)); + } + + if (typeof score === 'number') { + parts.push('Outcome score: ' + score.toFixed(2)); + } + + var result = parts.join('\n\n'); + if (result.length > CAPSULE_CONTENT_MAX_CHARS) { + result = result.slice(0, CAPSULE_CONTENT_MAX_CHARS) + '\n... [TRUNCATED]'; + } + return result || 'Evolution completed successfully.'; +} + +// --------------------------------------------------------------------------- +// Epigenetic Marks -- environmental imprints on Gene expression +// --------------------------------------------------------------------------- +// Epigenetic marks record environmental conditions under which a Gene performs +// well or poorly. Unlike mutations (which change the Gene itself), epigenetic +// marks modify expression strength without altering the underlying strategy. +// Marks propagate when Genes are reused (horizontal gene transfer) and decay +// over time (like biological DNA methylation patterns fading across generations). + +function buildEpigeneticMark(context, boost, reason) { + return { + context: String(context || '').slice(0, 100), + boost: Math.max(-0.5, Math.min(0.5, Number(boost) || 0)), + reason: String(reason || '').slice(0, 200), + created_at: new Date().toISOString(), + }; +} + +function applyEpigeneticMarks(gene, envFingerprint, outcomeStatus) { + if (!gene || gene.type !== 'Gene') return gene; + + // Initialize epigenetic_marks array if not present + if (!Array.isArray(gene.epigenetic_marks)) { + gene.epigenetic_marks = []; + } + + const platform = envFingerprint && envFingerprint.platform ? String(envFingerprint.platform) : ''; + const arch = envFingerprint && envFingerprint.arch ? String(envFingerprint.arch) : ''; + const nodeVersion = envFingerprint && envFingerprint.node_version ? String(envFingerprint.node_version) : ''; + const envContext = [platform, arch, nodeVersion].filter(Boolean).join('/') || 'unknown'; + + // Check if a mark for this context already exists + const existingIdx = gene.epigenetic_marks.findIndex( + (m) => m && m.context === envContext + ); + + if (outcomeStatus === 'success') { + if (existingIdx >= 0) { + // Reinforce: increase boost (max 0.5) + const cur = gene.epigenetic_marks[existingIdx]; + cur.boost = Math.min(0.5, (Number(cur.boost) || 0) + 0.05); + cur.reason = 'reinforced_by_success'; + cur.created_at = new Date().toISOString(); + } else { + // New positive mark + gene.epigenetic_marks.push( + buildEpigeneticMark(envContext, 0.1, 'success_in_environment') + ); + } + } else if (outcomeStatus === 'failed') { + if (existingIdx >= 0) { + // Suppress: decrease boost + const cur = gene.epigenetic_marks[existingIdx]; + cur.boost = Math.max(-0.5, (Number(cur.boost) || 0) - 0.1); + cur.reason = 'suppressed_by_failure'; + cur.created_at = new Date().toISOString(); + } else { + // New negative mark + gene.epigenetic_marks.push( + buildEpigeneticMark(envContext, -0.1, 'failure_in_environment') + ); + } + } + + // Decay old marks (keep max 10, remove marks older than 90 days) + const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; + gene.epigenetic_marks = gene.epigenetic_marks + .filter((m) => m && new Date(m.created_at).getTime() > cutoff) + .slice(-10); + + return gene; +} + +function getEpigeneticBoost(gene, envFingerprint) { + if (!gene || !Array.isArray(gene.epigenetic_marks)) return 0; + const platform = envFingerprint && envFingerprint.platform ? String(envFingerprint.platform) : ''; + const arch = envFingerprint && envFingerprint.arch ? String(envFingerprint.arch) : ''; + const nodeVersion = envFingerprint && envFingerprint.node_version ? String(envFingerprint.node_version) : ''; + const envContext = [platform, arch, nodeVersion].filter(Boolean).join('/') || 'unknown'; + + const mark = gene.epigenetic_marks.find((m) => m && m.context === envContext); + return mark ? Number(mark.boost) || 0 : 0; +} + +function buildAutoGene({ signals, intent }) { + const sigs = Array.isArray(signals) ? Array.from(new Set(signals.map(String))).filter(Boolean) : []; + const signalKey = computeSignalKey(sigs); + const id = `gene_auto_${stableHash(signalKey)}`; + const category = intent && ['repair', 'optimize', 'innovate'].includes(String(intent)) + ? String(intent) + : inferCategoryFromSignals(sigs); + const signalsMatch = sigs.length ? sigs.slice(0, 8) : ['(none)']; + const gene = { + type: 'Gene', + schema_version: SCHEMA_VERSION, + id, + category, + signals_match: signalsMatch, + preconditions: [`signals_key == ${signalKey}`], + strategy: [ + 'Extract structured signals from logs and user instructions', + 'Select an existing Gene by signals match (no improvisation)', + 'Estimate blast radius (files, lines) before editing and record it', + 'Apply smallest reversible patch', + 'Validate using declared validation steps; rollback on failure', + 'Solidify knowledge: append EvolutionEvent, update Gene/Capsule store', + ], + constraints: { + max_files: 12, + forbidden_paths: [ + '.git', 'node_modules', + 'skills/feishu-evolver-wrapper', 'skills/feishu-common', + 'skills/feishu-post', 'skills/feishu-card', 'skills/feishu-doc', + 'skills/skill-tools', 'skills/clawhub', 'skills/clawhub-batch-undelete', + 'skills/git-sync', + ], + }, + validation: ['node scripts/validate-modules.js ./src/gep/solidify'], + epigenetic_marks: [], // Epigenetic marks: environment-specific expression modifiers + }; + gene.asset_id = computeAssetId(gene); + return gene; +} + +function ensureGene({ genes, selectedGene, signals, intent, dryRun }) { + if (selectedGene && selectedGene.type === 'Gene') return { gene: selectedGene, created: false, reason: 'selected_gene_id_present' }; + const res = selectGene(Array.isArray(genes) ? genes : [], Array.isArray(signals) ? signals : [], { + bannedGeneIds: new Set(), preferredGeneId: null, driftEnabled: false, + }); + if (res && res.selected) return { gene: res.selected, created: false, reason: 'reselected_from_existing' }; + const auto = buildAutoGene({ signals, intent }); + if (!dryRun) upsertGene(auto); + return { gene: auto, created: true, reason: 'no_match_create_new' }; +} + +function readRecentSessionInputs() { + const repoRoot = getRepoRoot(); + const memoryDir = getMemoryDir(); + const rootMemory = path.join(repoRoot, 'MEMORY.md'); + const dirMemory = path.join(memoryDir, 'MEMORY.md'); + const memoryFile = fs.existsSync(rootMemory) ? rootMemory : dirMemory; + const userFile = path.join(repoRoot, 'USER.md'); + const todayLog = path.join(memoryDir, new Date().toISOString().split('T')[0] + '.md'); + const todayLogContent = fs.existsSync(todayLog) ? fs.readFileSync(todayLog, 'utf8') : ''; + const memorySnippet = fs.existsSync(memoryFile) ? fs.readFileSync(memoryFile, 'utf8').slice(0, 50000) : ''; + const userSnippet = fs.existsSync(userFile) ? fs.readFileSync(userFile, 'utf8') : ''; + const recentSessionTranscript = ''; + return { recentSessionTranscript, todayLog: todayLogContent, memorySnippet, userSnippet }; +} + +function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } = {}) { + const repoRoot = getRepoRoot(); + const state = readStateForSolidify(); + const lastRun = state && state.last_run ? state.last_run : null; + const genes = loadGenes(); + const geneId = lastRun && lastRun.selected_gene_id ? String(lastRun.selected_gene_id) : null; + const selectedGene = geneId ? genes.find(g => g && g.type === 'Gene' && g.id === geneId) : null; + const parentEventId = + lastRun && typeof lastRun.parent_event_id === 'string' ? lastRun.parent_event_id : getLastEventId(); + const signals = + lastRun && Array.isArray(lastRun.signals) && lastRun.signals.length + ? Array.from(new Set(lastRun.signals.map(String))) + : extractSignals(readRecentSessionInputs()); + const signalKey = computeSignalKey(signals); + + const mutationRaw = lastRun && lastRun.mutation && typeof lastRun.mutation === 'object' ? lastRun.mutation : null; + const personalityRaw = + lastRun && lastRun.personality_state && typeof lastRun.personality_state === 'object' ? lastRun.personality_state : null; + const mutation = mutationRaw && isValidMutation(mutationRaw) ? normalizeMutation(mutationRaw) : null; + const personalityState = + personalityRaw && isValidPersonalityState(personalityRaw) ? normalizePersonalityState(personalityRaw) : null; + const personalityKeyUsed = personalityState ? personalityKey(personalityState) : null; + const protocolViolations = []; + if (!mutation) protocolViolations.push('missing_or_invalid_mutation'); + if (!personalityState) protocolViolations.push('missing_or_invalid_personality_state'); + if (mutation && mutation.risk_level === 'high' && !isHighRiskMutationAllowed(personalityState || null)) { + protocolViolations.push('high_risk_mutation_not_allowed_by_personality'); + } + if (mutation && mutation.risk_level === 'high' && !(lastRun && lastRun.personality_known)) { + protocolViolations.push('high_risk_mutation_forbidden_under_unknown_personality'); + } + if (mutation && mutation.category === 'innovate' && personalityState && isHighRiskPersonality(personalityState)) { + protocolViolations.push('forbidden_innovate_with_high_risk_personality'); + } + + const ensured = ensureGene({ genes, selectedGene, signals, intent, dryRun: !!dryRun }); + const geneUsed = ensured.gene; + const blast = computeBlastRadius({ + repoRoot, + baselineUntracked: lastRun && Array.isArray(lastRun.baseline_untracked) ? lastRun.baseline_untracked : [], + }); + const blastRadiusEstimate = lastRun && lastRun.blast_radius_estimate ? lastRun.blast_radius_estimate : null; + const constraintCheck = checkConstraints({ gene: geneUsed, blast, blastRadiusEstimate, repoRoot }); + + // Log blast radius diagnostics when severity is elevated. + if (constraintCheck.blastSeverity && + constraintCheck.blastSeverity.severity !== 'within_limit' && + constraintCheck.blastSeverity.severity !== 'approaching_limit') { + const breakdown = analyzeBlastRadiusBreakdown(blast.all_changed_files || blast.changed_files || []); + console.error(`[Solidify] Blast radius breakdown: ${JSON.stringify(breakdown)}`); + const estComp = compareBlastEstimate(blastRadiusEstimate, blast); + if (estComp) { + console.error(`[Solidify] Estimate comparison: estimated ${estComp.estimateFiles} files, actual ${estComp.actualFiles} files (${estComp.ratio}x)`); + } + } + + // Log warnings even on success (approaching limit, estimate drift). + if (constraintCheck.warnings && constraintCheck.warnings.length > 0) { + for (const w of constraintCheck.warnings) { + console.log(`[Solidify] WARNING: ${w}`); + } + } + + // Critical safety: detect destructive changes to core dependencies. + const destructiveViolations = detectDestructiveChanges({ + repoRoot, + changedFiles: blast.all_changed_files || blast.changed_files || [], + baselineUntracked: lastRun && Array.isArray(lastRun.baseline_untracked) ? lastRun.baseline_untracked : [], + }); + if (destructiveViolations.length > 0) { + for (const v of destructiveViolations) { + constraintCheck.violations.push(v); + } + constraintCheck.ok = false; + console.error(`[Solidify] CRITICAL: Destructive changes detected: ${destructiveViolations.join('; ')}`); + } + + // Capture environment fingerprint before validation. + const envFp = captureEnvFingerprint(); + + let validation = { ok: true, results: [], startedAt: null, finishedAt: null }; + if (geneUsed) { + validation = runValidations(geneUsed, { repoRoot, timeoutMs: 180000 }); + } + + // Canary safety: verify index.js loads in an isolated child process. + // This catches broken entry points that gene validations might miss. + const canary = runCanaryCheck({ repoRoot, timeoutMs: 30000 }); + if (!canary.ok && !canary.skipped) { + constraintCheck.violations.push( + `canary_failed: index.js cannot load in child process: ${canary.err}` + ); + constraintCheck.ok = false; + console.error(`[Solidify] CANARY FAILED: ${canary.err}`); + } + + // Build standardized ValidationReport (machine-readable, interoperable). + const validationReport = buildValidationReport({ + geneId: geneUsed && geneUsed.id ? geneUsed.id : null, + commands: validation.results.map(function (r) { return r.cmd; }), + results: validation.results, + envFp: envFp, + startedAt: validation.startedAt, + finishedAt: validation.finishedAt, + }); + + const success = constraintCheck.ok && validation.ok && protocolViolations.length === 0; + const ts = nowIso(); + const outcomeStatus = success ? 'success' : 'failed'; + const score = clamp01(success ? 0.85 : 0.2); + + const selectedCapsuleId = + lastRun && typeof lastRun.selected_capsule_id === 'string' && lastRun.selected_capsule_id.trim() + ? String(lastRun.selected_capsule_id).trim() : null; + const capsuleId = success ? selectedCapsuleId || buildCapsuleId(ts) : null; + const derivedIntent = intent || (mutation && mutation.category) || (geneUsed && geneUsed.category) || 'repair'; + const intentMismatch = + intent && mutation && typeof mutation.category === 'string' && String(intent) !== String(mutation.category); + if (intentMismatch) protocolViolations.push(`intent_mismatch_with_mutation:${String(intent)}!=${String(mutation.category)}`); + + const sourceType = lastRun && lastRun.source_type ? String(lastRun.source_type) : 'generated'; + const reusedAssetId = lastRun && lastRun.reused_asset_id ? String(lastRun.reused_asset_id) : null; + const reusedChainId = lastRun && lastRun.reused_chain_id ? String(lastRun.reused_chain_id) : null; + + // LessonL: carry applied lesson IDs for Hub effectiveness adjustment + const appliedLessons = lastRun && Array.isArray(lastRun.applied_lessons) ? lastRun.applied_lessons : []; + + const event = { + type: 'EvolutionEvent', + schema_version: SCHEMA_VERSION, + id: buildEventId(ts), + parent: parentEventId || null, + intent: derivedIntent, + signals, + genes_used: geneUsed && geneUsed.id ? [geneUsed.id] : [], + mutation_id: mutation && mutation.id ? mutation.id : null, + personality_state: personalityState || null, + blast_radius: { files: blast.files, lines: blast.lines }, + outcome: { status: outcomeStatus, score }, + capsule_id: capsuleId, + source_type: sourceType, + reused_asset_id: reusedAssetId, + ...(appliedLessons.length > 0 ? { applied_lessons: appliedLessons } : {}), + env_fingerprint: envFp, + validation_report_id: validationReport.id, + meta: { + at: ts, + signal_key: signalKey, + selector: lastRun && lastRun.selector ? lastRun.selector : null, + blast_radius_estimate: lastRun && lastRun.blast_radius_estimate ? lastRun.blast_radius_estimate : null, + mutation: mutation || null, + personality: { + key: personalityKeyUsed, + known: !!(lastRun && lastRun.personality_known), + mutations: lastRun && Array.isArray(lastRun.personality_mutations) ? lastRun.personality_mutations : [], + }, + gene: { + id: geneUsed && geneUsed.id ? geneUsed.id : null, + created: !!ensured.created, + reason: ensured.reason, + }, + constraints_ok: constraintCheck.ok, + constraint_violations: constraintCheck.violations, + constraint_warnings: constraintCheck.warnings || [], + blast_severity: constraintCheck.blastSeverity ? constraintCheck.blastSeverity.severity : null, + blast_breakdown: (!constraintCheck.ok && blast) + ? analyzeBlastRadiusBreakdown(blast.all_changed_files || blast.changed_files || []) + : null, + blast_estimate_comparison: compareBlastEstimate(blastRadiusEstimate, blast), + validation_ok: validation.ok, + validation: validation.results.map(r => ({ cmd: r.cmd, ok: r.ok })), + validation_report: validationReport, + canary_ok: canary.ok, + canary_skipped: !!canary.skipped, + protocol_ok: protocolViolations.length === 0, + protocol_violations: protocolViolations, + memory_graph: memoryGraphPath(), + }, + }; + event.asset_id = computeAssetId(event); + + let capsule = null; + if (success) { + const s = String(summary || '').trim(); + const autoSummary = geneUsed + ? `固化:${geneUsed.id} 命中信号 ${signals.join(', ') || '(none)'},变更 ${blast.files} 文件 / ${blast.lines} 行。` + : `固化:命中信号 ${signals.join(', ') || '(none)'},变更 ${blast.files} 文件 / ${blast.lines} 行。`; + let prevCapsule = null; + try { + if (selectedCapsuleId) { + const list = require('./assetStore').loadCapsules(); + prevCapsule = Array.isArray(list) ? list.find(c => c && c.type === 'Capsule' && String(c.id) === selectedCapsuleId) : null; + } + } catch (e) {} + const successReason = buildSuccessReason({ gene: geneUsed, signals, blast, mutation, score }); + const capsuleDiff = captureDiffSnapshot(repoRoot); + const capsuleContent = buildCapsuleContent({ intent, gene: geneUsed, signals, blast, mutation, score }); + const capsuleStrategy = geneUsed && Array.isArray(geneUsed.strategy) && geneUsed.strategy.length > 0 + ? geneUsed.strategy : undefined; + capsule = { + type: 'Capsule', + schema_version: SCHEMA_VERSION, + id: capsuleId, + trigger: prevCapsule && Array.isArray(prevCapsule.trigger) && prevCapsule.trigger.length ? prevCapsule.trigger : signals, + gene: geneUsed && geneUsed.id ? geneUsed.id : prevCapsule && prevCapsule.gene ? prevCapsule.gene : null, + summary: s || (prevCapsule && prevCapsule.summary ? String(prevCapsule.summary) : autoSummary), + confidence: clamp01(score), + blast_radius: { files: blast.files, lines: blast.lines }, + outcome: { status: 'success', score }, + success_streak: 1, + success_reason: successReason, + env_fingerprint: envFp, + source_type: sourceType, + reused_asset_id: reusedAssetId, + a2a: { eligible_to_broadcast: false }, + content: capsuleContent, + diff: capsuleDiff || undefined, + strategy: capsuleStrategy, + }; + capsule.asset_id = computeAssetId(capsule); + } + + // Capture failed mutation as a FailedCapsule before rollback destroys the diff. + if (!dryRun && !success) { + try { + var diffSnapshot = captureDiffSnapshot(repoRoot); + if (diffSnapshot) { + var failedCapsule = { + type: 'Capsule', + schema_version: SCHEMA_VERSION, + id: 'failed_' + buildCapsuleId(ts), + outcome: { status: 'failed', score: score }, + gene: geneUsed && geneUsed.id ? geneUsed.id : null, + trigger: Array.isArray(signals) ? signals.slice(0, 8) : [], + summary: geneUsed + ? 'Failed: ' + geneUsed.id + ' on signals [' + (signals.slice(0, 3).join(', ') || 'none') + ']' + : 'Failed evolution on signals [' + (signals.slice(0, 3).join(', ') || 'none') + ']', + diff_snapshot: diffSnapshot, + failure_reason: buildFailureReason(constraintCheck, validation, protocolViolations, canary), + constraint_violations: constraintCheck.violations || [], + env_fingerprint: envFp, + blast_radius: { files: blast.files, lines: blast.lines }, + created_at: ts, + }; + failedCapsule.asset_id = computeAssetId(failedCapsule); + appendFailedCapsule(failedCapsule); + console.log('[Solidify] Preserved failed mutation as FailedCapsule: ' + failedCapsule.id); + } + } catch (e) { + console.log('[Solidify] FailedCapsule capture error (non-fatal): ' + (e && e.message ? e.message : e)); + } + } + + if (!dryRun && !success && rollbackOnFailure) { + rollbackTracked(repoRoot); + rollbackNewUntrackedFiles({ repoRoot, baselineUntracked: lastRun && lastRun.baseline_untracked ? lastRun.baseline_untracked : [] }); + } + + // Apply epigenetic marks to the gene based on outcome and environment + if (!dryRun && geneUsed && geneUsed.type === 'Gene') { + try { + applyEpigeneticMarks(geneUsed, envFp, outcomeStatus); + upsertGene(geneUsed); + } catch (e) { + // Non-blocking: epigenetic mark failure must not break solidify + } + } + + if (!dryRun) { + appendEventJsonl(validationReport); + if (capsule) upsertCapsule(capsule); + appendEventJsonl(event); + if (capsule) { + const streak = computeCapsuleSuccessStreak({ capsuleId: capsule.id }); + capsule.success_streak = streak || 1; + capsule.a2a = { + eligible_to_broadcast: + isBlastRadiusSafe(capsule.blast_radius) && + (capsule.outcome.score || 0) >= 0.7 && + (capsule.success_streak || 0) >= 2, + }; + capsule.asset_id = computeAssetId(capsule); + upsertCapsule(capsule); + } + try { + if (personalityState) { + updatePersonalityStats({ personalityState, outcome: outcomeStatus, score, notes: `event:${event.id}` }); + } + } catch (e) {} + } + + const runId = lastRun && lastRun.run_id ? String(lastRun.run_id) : stableHash(`${parentEventId || 'root'}|${geneId || 'none'}|${signalKey}`); + state.last_solidify = { + run_id: runId, at: ts, event_id: event.id, capsule_id: capsuleId, outcome: event.outcome, + }; + if (!dryRun) writeStateForSolidify(state); + + // Search-First Evolution: auto-publish eligible capsules to the Hub (as Gene+Capsule bundle). + let publishResult = null; + if (!dryRun && capsule && capsule.a2a && capsule.a2a.eligible_to_broadcast) { + const autoPublish = String(process.env.EVOLVER_AUTO_PUBLISH || 'true').toLowerCase() !== 'false'; + const visibility = String(process.env.EVOLVER_DEFAULT_VISIBILITY || 'public').toLowerCase(); + const minPublishScore = Number(process.env.EVOLVER_MIN_PUBLISH_SCORE) || 0.78; + + // Skip publishing if: disabled, private, direct-reused asset, or below minimum score. + // 'reference' mode produces a new capsule inspired by hub -- eligible for publish. + if (autoPublish && visibility === 'public' && sourceType !== 'reused' && (capsule.outcome.score || 0) >= minPublishScore) { + try { + const { buildPublishBundle, httpTransportSend } = require('./a2aProtocol'); + const { sanitizePayload } = require('./sanitize'); + const hubUrl = (process.env.A2A_HUB_URL || '').replace(/\/+$/, ''); + + if (hubUrl) { + // Hub requires bundle format: Gene + Capsule published together. + // Build a Gene object from geneUsed if available; otherwise synthesize a minimal Gene. + var publishGene = null; + if (geneUsed && geneUsed.type === 'Gene' && geneUsed.id) { + publishGene = sanitizePayload(geneUsed); + } else { + publishGene = { + type: 'Gene', + id: capsule.gene || ('gene_auto_' + (capsule.id || Date.now())), + category: event && event.intent ? event.intent : 'repair', + signals_match: Array.isArray(capsule.trigger) ? capsule.trigger : [], + summary: capsule.summary || '', + }; + } + var parentRef = reusedAssetId && sourceType === 'reference' && String(reusedAssetId).startsWith('sha256:') + ? reusedAssetId : null; + if (parentRef) { + publishGene.parent = parentRef; + } + publishGene.asset_id = computeAssetId(publishGene); + + var sanitizedCapsule = sanitizePayload(capsule); + if (parentRef) { + sanitizedCapsule.parent = parentRef; + } + sanitizedCapsule.asset_id = computeAssetId(sanitizedCapsule); + + var sanitizedEvent = (event && event.type === 'EvolutionEvent') ? sanitizePayload(event) : null; + if (sanitizedEvent) sanitizedEvent.asset_id = computeAssetId(sanitizedEvent); + + var publishChainId = reusedChainId || null; + + var evolverModelName = (process.env.EVOLVER_MODEL_NAME || '').trim().slice(0, 100); + + var msg = buildPublishBundle({ + gene: publishGene, + capsule: sanitizedCapsule, + event: sanitizedEvent, + chainId: publishChainId, + modelName: evolverModelName || undefined, + }); + var result = httpTransportSend(msg, { hubUrl }); + // httpTransportSend returns a Promise + if (result && typeof result.then === 'function') { + result + .then(function (res) { + if (res && res.ok) { + console.log('[AutoPublish] Published bundle (Gene+Capsule) ' + (capsule.asset_id || capsule.id) + ' to Hub.'); + } else { + console.log('[AutoPublish] Hub rejected: ' + JSON.stringify(res)); + } + }) + .catch(function (err) { + console.log('[AutoPublish] Failed (non-fatal): ' + err.message); + }); + } + publishResult = { attempted: true, asset_id: capsule.asset_id || capsule.id, bundle: true }; + } else { + publishResult = { attempted: false, reason: 'no_hub_url' }; + } + } catch (e) { + console.log('[AutoPublish] Error (non-fatal): ' + e.message); + publishResult = { attempted: false, reason: e.message }; + } + } else { + const reason = !autoPublish ? 'auto_publish_disabled' + : visibility !== 'public' ? 'visibility_private' + : sourceType === 'reused' ? 'skip_direct_reused_asset' + : 'below_min_score'; + publishResult = { attempted: false, reason }; + } + } + + // --- Anti-pattern auto-publish --- + // Publish high-information-value failures to the Hub as anti-pattern assets. + // Only enabled via EVOLVER_PUBLISH_ANTI_PATTERNS=true (opt-in). + // Only constraint violations or canary failures qualify (not routine validation failures). + var antiPatternPublishResult = null; + if (!dryRun && !success) { + var publishAntiPatterns = String(process.env.EVOLVER_PUBLISH_ANTI_PATTERNS || '').toLowerCase() === 'true'; + var hubUrl = (process.env.A2A_HUB_URL || '').replace(/\/+$/, ''); + var hasHighInfoFailure = (constraintCheck.violations && constraintCheck.violations.length > 0) + || (canary && !canary.ok && !canary.skipped); + if (publishAntiPatterns && hubUrl && hasHighInfoFailure) { + try { + var { buildPublishBundle: buildApBundle, httpTransportSend: httpApSend } = require('./a2aProtocol'); + var { sanitizePayload: sanitizeAp } = require('./sanitize'); + var apGene = geneUsed && geneUsed.type === 'Gene' && geneUsed.id + ? sanitizeAp(geneUsed) + : { type: 'Gene', id: 'gene_unknown_' + Date.now(), category: derivedIntent, signals_match: signals.slice(0, 8), summary: 'Failed evolution gene' }; + apGene.anti_pattern = true; + apGene.failure_reason = buildFailureReason(constraintCheck, validation, protocolViolations, canary); + apGene.asset_id = computeAssetId(apGene); + var apCapsule = { + type: 'Capsule', + schema_version: SCHEMA_VERSION, + id: 'failed_' + buildCapsuleId(ts), + trigger: signals.slice(0, 8), + gene: apGene.id, + summary: 'Anti-pattern: ' + String(apGene.failure_reason).slice(0, 200), + confidence: 0, + blast_radius: { files: blast.files, lines: blast.lines }, + outcome: { status: 'failed', score: score }, + failure_reason: apGene.failure_reason, + a2a: { eligible_to_broadcast: false }, + }; + apCapsule.asset_id = computeAssetId(apCapsule); + var apModelName = (process.env.EVOLVER_MODEL_NAME || '').trim().slice(0, 100); + var apMsg = buildApBundle({ gene: apGene, capsule: sanitizeAp(apCapsule), event: null, modelName: apModelName || undefined }); + var apResult = httpApSend(apMsg, { hubUrl }); + if (apResult && typeof apResult.then === 'function') { + apResult + .then(function (res) { + if (res && res.ok) console.log('[AntiPatternPublish] Published failed bundle to Hub: ' + apCapsule.id); + else console.log('[AntiPatternPublish] Hub rejected: ' + JSON.stringify(res)); + }) + .catch(function (err) { + console.log('[AntiPatternPublish] Failed (non-fatal): ' + err.message); + }); + } + antiPatternPublishResult = { attempted: true, asset_id: apCapsule.asset_id }; + } catch (e) { + console.log('[AntiPatternPublish] Error (non-fatal): ' + e.message); + antiPatternPublishResult = { attempted: false, reason: e.message }; + } + } + } + + // --- LessonL: Auto-publish negative lesson to Hub (always-on, lightweight) --- + // Unlike anti-pattern publishing (opt-in, full capsule bundle), this publishes + // just the failure reason as a structured lesson via the EvolutionEvent. + // The Hub's solicitLesson() hook on handlePublish will extract the lesson. + // This is achieved by ensuring failure_reason is included in the event metadata, + // which we already do above. The Hub-side solicitLesson() handles the rest. + // For failures without a published event (no auto-publish), we still log locally. + if (!dryRun && !success && event && event.outcome) { + var failureContent = buildFailureReason(constraintCheck, validation, protocolViolations, canary); + event.failure_reason = failureContent; + event.summary = geneUsed + ? 'Failed: ' + geneUsed.id + ' on signals [' + (signals.slice(0, 3).join(', ') || 'none') + '] - ' + failureContent.slice(0, 200) + : 'Failed evolution on signals [' + (signals.slice(0, 3).join(', ') || 'none') + '] - ' + failureContent.slice(0, 200); + } + + // --- Auto-complete Hub task --- + // If this evolution cycle was driven by a Hub task, mark it as completed + // with the produced capsule's asset_id. Runs after publish so the Hub + // can link the task result to the published asset. + let taskCompleteResult = null; + if (!dryRun && success && lastRun && lastRun.active_task_id) { + const resultAssetId = capsule && capsule.asset_id ? capsule.asset_id : (capsule && capsule.id ? capsule.id : null); + if (resultAssetId) { + try { + const { completeTask } = require('./taskReceiver'); + const taskId = String(lastRun.active_task_id); + console.log(`[TaskComplete] Completing task "${lastRun.active_task_title || taskId}" with asset ${resultAssetId}`); + const completed = completeTask(taskId, resultAssetId); + if (completed && typeof completed.then === 'function') { + completed + .then(function (ok) { + if (ok) { + console.log('[TaskComplete] Task completed successfully on Hub.'); + } else { + console.log('[TaskComplete] Hub rejected task completion (non-fatal).'); + } + }) + .catch(function (err) { + console.log('[TaskComplete] Failed (non-fatal): ' + (err && err.message ? err.message : err)); + }); + } + taskCompleteResult = { attempted: true, task_id: taskId, asset_id: resultAssetId }; + } catch (e) { + console.log('[TaskComplete] Error (non-fatal): ' + e.message); + taskCompleteResult = { attempted: false, reason: e.message }; + } + } + } + + return { ok: success, event, capsule, gene: geneUsed, constraintCheck, validation, validationReport, blast, publishResult, antiPatternPublishResult, taskCompleteResult }; +} + +module.exports = { + solidify, + readStateForSolidify, + writeStateForSolidify, + isValidationCommandAllowed, + isCriticalProtectedPath, + detectDestructiveChanges, + classifyBlastSeverity, + analyzeBlastRadiusBreakdown, + compareBlastEstimate, + runCanaryCheck, + applyEpigeneticMarks, + getEpigeneticBoost, + buildEpigeneticMark, + buildSuccessReason, + BLAST_RADIUS_HARD_CAP_FILES, + BLAST_RADIUS_HARD_CAP_LINES, +}; diff --git a/skills/capability-evolver/src/gep/strategy.js b/skills/capability-evolver/src/gep/strategy.js new file mode 100644 index 0000000..c35a12a --- /dev/null +++ b/skills/capability-evolver/src/gep/strategy.js @@ -0,0 +1,126 @@ +// Evolution Strategy Presets (v1.1) +// Controls the balance between repair, optimize, and innovate intents. +// +// Usage: set EVOLVE_STRATEGY env var to one of: balanced, innovate, harden, repair-only, +// early-stabilize, steady-state, or "auto" for adaptive selection. +// Default: balanced (or auto-detected based on cycle count / saturation signals) +// +// Each strategy defines: +// repair/optimize/innovate - target allocation ratios (inform the LLM prompt) +// repairLoopThreshold - repair ratio in last 8 cycles that triggers forced innovation +// label - human-readable name injected into the GEP prompt + +var fs = require('fs'); +var path = require('path'); + +var STRATEGIES = { + 'balanced': { + repair: 0.20, + optimize: 0.30, + innovate: 0.50, + repairLoopThreshold: 0.50, + label: 'Balanced', + description: 'Normal operation. Steady growth with stability.', + }, + 'innovate': { + repair: 0.05, + optimize: 0.15, + innovate: 0.80, + repairLoopThreshold: 0.30, + label: 'Innovation Focus', + description: 'System is stable. Maximize new features and capabilities.', + }, + 'harden': { + repair: 0.40, + optimize: 0.40, + innovate: 0.20, + repairLoopThreshold: 0.70, + label: 'Hardening', + description: 'After a big change. Focus on stability and robustness.', + }, + 'repair-only': { + repair: 0.80, + optimize: 0.20, + innovate: 0.00, + repairLoopThreshold: 1.00, + label: 'Repair Only', + description: 'Emergency. Fix everything before doing anything else.', + }, + 'early-stabilize': { + repair: 0.60, + optimize: 0.25, + innovate: 0.15, + repairLoopThreshold: 0.80, + label: 'Early Stabilization', + description: 'First cycles. Prioritize fixing existing issues before innovating.', + }, + 'steady-state': { + repair: 0.60, + optimize: 0.30, + innovate: 0.10, + repairLoopThreshold: 0.90, + label: 'Steady State', + description: 'Evolution saturated. Maintain existing capabilities. Minimal innovation.', + }, +}; + +// Read evolution_state.json to get the current cycle count for auto-detection. +function _readCycleCount() { + try { + // evolver/memory/evolution_state.json (local to the skill) + var localPath = path.resolve(__dirname, '..', '..', 'memory', 'evolution_state.json'); + // workspace/memory/evolution/evolution_state.json (canonical path used by evolve.js) + var workspacePath = path.resolve(__dirname, '..', '..', '..', '..', 'memory', 'evolution', 'evolution_state.json'); + var candidates = [localPath, workspacePath]; + for (var i = 0; i < candidates.length; i++) { + if (fs.existsSync(candidates[i])) { + var data = JSON.parse(fs.readFileSync(candidates[i], 'utf8')); + return data && Number.isFinite(data.cycleCount) ? data.cycleCount : 0; + } + } + } catch (e) {} + return 0; +} + +function resolveStrategy(opts) { + var signals = (opts && Array.isArray(opts.signals)) ? opts.signals : []; + var name = String(process.env.EVOLVE_STRATEGY || 'balanced').toLowerCase().trim(); + + // Backward compatibility: FORCE_INNOVATION=true maps to 'innovate' + if (!process.env.EVOLVE_STRATEGY) { + var fi = String(process.env.FORCE_INNOVATION || process.env.EVOLVE_FORCE_INNOVATION || '').toLowerCase(); + if (fi === 'true') name = 'innovate'; + } + + // Auto-detection: when no explicit strategy is set (defaults to 'balanced'), + // apply heuristics inspired by Echo-MingXuan's "fix first, innovate later" pattern. + var isDefault = !process.env.EVOLVE_STRATEGY || name === 'balanced' || name === 'auto'; + + if (isDefault) { + // Early-stabilize: first 5 cycles should focus on fixing existing issues. + var cycleCount = _readCycleCount(); + if (cycleCount > 0 && cycleCount <= 5) { + name = 'early-stabilize'; + } + + // Saturation detection: if saturation signals are present, switch to steady-state. + if (signals.indexOf('force_steady_state') !== -1) { + name = 'steady-state'; + } else if (signals.indexOf('evolution_saturation') !== -1) { + name = 'steady-state'; + } + } + + // Explicit "auto" maps to whatever was auto-detected above (or balanced if no heuristic fired). + if (name === 'auto') name = 'balanced'; + + var strategy = STRATEGIES[name] || STRATEGIES['balanced']; + strategy.name = name; + return strategy; +} + +function getStrategyNames() { + return Object.keys(STRATEGIES); +} + +module.exports = { resolveStrategy, getStrategyNames, STRATEGIES }; diff --git a/skills/capability-evolver/src/gep/taskReceiver.js b/skills/capability-evolver/src/gep/taskReceiver.js new file mode 100644 index 0000000..743a132 --- /dev/null +++ b/skills/capability-evolver/src/gep/taskReceiver.js @@ -0,0 +1,373 @@ +// --------------------------------------------------------------------------- +// taskReceiver -- pulls external tasks from Hub, auto-claims, and injects +// them as high-priority signals into the evolution loop. +// +// v2: Smart task selection with difficulty-aware ROI scoring and capability +// matching via memory graph history. +// --------------------------------------------------------------------------- + +const { getNodeId } = require('./a2aProtocol'); + +const HUB_URL = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || 'https://evomap.ai'; + +const TASK_STRATEGY = String(process.env.TASK_STRATEGY || 'balanced').toLowerCase(); +const TASK_MIN_CAPABILITY_MATCH = Number(process.env.TASK_MIN_CAPABILITY_MATCH) || 0.1; + +// Scoring weights by strategy +const STRATEGY_WEIGHTS = { + greedy: { roi: 0.10, capability: 0.05, completion: 0.05, bounty: 0.80 }, + balanced: { roi: 0.35, capability: 0.30, completion: 0.20, bounty: 0.15 }, + conservative: { roi: 0.25, capability: 0.45, completion: 0.25, bounty: 0.05 }, +}; + +/** + * Fetch available tasks from Hub via the A2A fetch endpoint. + * Optionally piggybacks proactive questions in the payload for Hub to create bounties. + * + * @param {object} [opts] + * @param {Array<{ question: string, amount?: number, signals?: string[] }>} [opts.questions] + * @returns {{ tasks: Array, questions_created?: Array }} + */ +async function fetchTasks(opts) { + const o = opts || {}; + const nodeId = getNodeId(); + if (!nodeId) return { tasks: [] }; + + try { + const payload = { + asset_type: null, + include_tasks: true, + }; + + if (Array.isArray(o.questions) && o.questions.length > 0) { + payload.questions = o.questions; + } + + const msg = { + protocol: 'gep-a2a', + protocol_version: '1.0.0', + message_type: 'fetch', + message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + sender_id: nodeId, + timestamp: new Date().toISOString(), + payload, + }; + + const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/fetch`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(msg), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) return { tasks: [] }; + + const data = await res.json(); + const respPayload = data.payload || data; + const tasks = Array.isArray(respPayload.tasks) ? respPayload.tasks : []; + const result = { tasks }; + + if (respPayload.questions_created) { + result.questions_created = respPayload.questions_created; + } + + // LessonL: extract relevant lessons from Hub response + if (Array.isArray(respPayload.relevant_lessons) && respPayload.relevant_lessons.length > 0) { + result.relevant_lessons = respPayload.relevant_lessons; + } + + return result; + } catch { + return { tasks: [] }; + } +} + +// --------------------------------------------------------------------------- +// Capability matching: how well this agent's history matches a task's signals +// --------------------------------------------------------------------------- + +function parseSignals(raw) { + if (!raw) return []; + return String(raw).split(',').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean); +} + +function jaccard(a, b) { + if (!a.length || !b.length) return 0; + var setA = new Set(a); + var setB = new Set(b); + var inter = 0; + for (var v of setB) { if (setA.has(v)) inter++; } + return inter / (setA.size + setB.size - inter); +} + +/** + * Estimate how well this agent can handle a task based on memory graph history. + * Returns 0.0 - 1.0 where 1.0 = strong match with high success rate. + * + * @param {object} task - task from Hub (has .signals field) + * @param {Array} memoryEvents - from tryReadMemoryGraphEvents() + * @returns {number} + */ +function estimateCapabilityMatch(task, memoryEvents) { + if (!Array.isArray(memoryEvents) || memoryEvents.length === 0) return 0.5; + + var taskSignals = parseSignals(task.signals || task.title); + if (taskSignals.length === 0) return 0.5; + + var successBySignalKey = {}; + var totalBySignalKey = {}; + var allSignals = {}; + + for (var i = 0; i < memoryEvents.length; i++) { + var ev = memoryEvents[i]; + if (!ev || ev.type !== 'MemoryGraphEvent' || ev.kind !== 'outcome') continue; + + var sigs = (ev.signal && Array.isArray(ev.signal.signals)) ? ev.signal.signals : []; + var key = (ev.signal && ev.signal.key) ? String(ev.signal.key) : ''; + var status = (ev.outcome && ev.outcome.status) ? String(ev.outcome.status) : ''; + + for (var j = 0; j < sigs.length; j++) { + allSignals[sigs[j].toLowerCase()] = true; + } + + if (!key) continue; + if (!totalBySignalKey[key]) { totalBySignalKey[key] = 0; successBySignalKey[key] = 0; } + totalBySignalKey[key]++; + if (status === 'success') successBySignalKey[key]++; + } + + // Jaccard overlap between task signals and all signals this agent has worked with + var allSigArr = Object.keys(allSignals); + var overlapScore = jaccard(taskSignals, allSigArr); + + // Weighted success rate across matching signal keys + var weightedSuccess = 0; + var weightSum = 0; + for (var sk in totalBySignalKey) { + // Reconstruct signals from the key for comparison + var skParts = sk.split(':').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean); + var sim = jaccard(taskSignals, skParts); + if (sim < 0.15) continue; + + var total = totalBySignalKey[sk]; + var succ = successBySignalKey[sk] || 0; + var rate = (succ + 1) / (total + 2); // Laplace smoothing + weightedSuccess += rate * sim; + weightSum += sim; + } + + var successScore = weightSum > 0 ? (weightedSuccess / weightSum) : 0.5; + + // Combine: 60% success rate history + 40% signal overlap + return Math.min(1, overlapScore * 0.4 + successScore * 0.6); +} + +// --------------------------------------------------------------------------- +// Local fallback difficulty estimation when Hub doesn't provide complexity_score +// --------------------------------------------------------------------------- + +function localDifficultyEstimate(task) { + var signals = parseSignals(task.signals); + var signalFactor = Math.min(signals.length / 8, 1); + + var titleWords = (task.title || '').split(/\s+/).filter(Boolean).length; + var titleFactor = Math.min(titleWords / 15, 1); + + return Math.min(1, signalFactor * 0.6 + titleFactor * 0.4); +} + +// --------------------------------------------------------------------------- +// Score a single task for this agent +// --------------------------------------------------------------------------- + +/** + * @param {object} task - task from Hub + * @param {number} capabilityMatch - from estimateCapabilityMatch() + * @returns {{ composite: number, factors: object }} + */ +function scoreTask(task, capabilityMatch) { + var w = STRATEGY_WEIGHTS[TASK_STRATEGY] || STRATEGY_WEIGHTS.balanced; + + var difficulty = (task.complexity_score != null) ? task.complexity_score : localDifficultyEstimate(task); + var bountyAmount = task.bounty_amount || 0; + var completionRate = (task.historical_completion_rate != null) ? task.historical_completion_rate : 0.5; + + // ROI: bounty per unit difficulty (higher = better value) + var roiRaw = bountyAmount / (difficulty + 0.1); + var roiNorm = Math.min(roiRaw / 200, 1); // normalize: 200-credit ROI = max + + // Bounty absolute: normalize against a reference max + var bountyNorm = Math.min(bountyAmount / 100, 1); + + var composite = + w.roi * roiNorm + + w.capability * capabilityMatch + + w.completion * completionRate + + w.bounty * bountyNorm; + + return { + composite: Math.round(composite * 1000) / 1000, + factors: { + roi: Math.round(roiNorm * 100) / 100, + capability: Math.round(capabilityMatch * 100) / 100, + completion: Math.round(completionRate * 100) / 100, + bounty: Math.round(bountyNorm * 100) / 100, + difficulty: Math.round(difficulty * 100) / 100, + }, + }; +} + +// --------------------------------------------------------------------------- +// Enhanced task selection with scoring +// --------------------------------------------------------------------------- + +/** + * Pick the best task from a list using composite scoring. + * @param {Array} tasks + * @param {Array} [memoryEvents] - from tryReadMemoryGraphEvents() + * @returns {object|null} + */ +function selectBestTask(tasks, memoryEvents) { + if (!Array.isArray(tasks) || tasks.length === 0) return null; + + var nodeId = getNodeId(); + + // Already-claimed tasks for this node always take top priority (resume work) + var myClaimedTask = tasks.find(function(t) { + return t.status === 'claimed' && t.claimed_by === nodeId; + }); + if (myClaimedTask) return myClaimedTask; + + // Filter to open tasks only + var open = tasks.filter(function(t) { return t.status === 'open'; }); + if (open.length === 0) return null; + + // Legacy greedy mode: preserve old behavior exactly + if (TASK_STRATEGY === 'greedy' && (!memoryEvents || memoryEvents.length === 0)) { + var bountyTasks = open.filter(function(t) { return t.bounty_id; }); + if (bountyTasks.length > 0) { + bountyTasks.sort(function(a, b) { return (b.bounty_amount || 0) - (a.bounty_amount || 0); }); + return bountyTasks[0]; + } + return open[0]; + } + + // Score all open tasks + var scored = open.map(function(t) { + var cap = estimateCapabilityMatch(t, memoryEvents || []); + var result = scoreTask(t, cap); + return { task: t, composite: result.composite, factors: result.factors, capability: cap }; + }); + + // Filter by minimum capability match (unless conservative skipping is off) + if (TASK_MIN_CAPABILITY_MATCH > 0) { + var filtered = scored.filter(function(s) { return s.capability >= TASK_MIN_CAPABILITY_MATCH; }); + if (filtered.length > 0) scored = filtered; + } + + scored.sort(function(a, b) { return b.composite - a.composite; }); + + // Log top 3 candidates for debugging + var top3 = scored.slice(0, 3); + for (var i = 0; i < top3.length; i++) { + var s = top3[i]; + console.log('[TaskStrategy] #' + (i + 1) + ' "' + (s.task.title || s.task.task_id || '').slice(0, 50) + '" score=' + s.composite + ' ' + JSON.stringify(s.factors)); + } + + return scored[0] ? scored[0].task : null; +} + +/** + * Claim a task on the Hub. + * @param {string} taskId + * @returns {boolean} true if claim succeeded + */ +async function claimTask(taskId) { + const nodeId = getNodeId(); + if (!nodeId || !taskId) return false; + + try { + const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/task/claim`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId, node_id: nodeId }), + signal: controller.signal, + }); + clearTimeout(timer); + + return res.ok; + } catch { + return false; + } +} + +/** + * Complete a task on the Hub with the result asset ID. + * @param {string} taskId + * @param {string} assetId + * @returns {boolean} + */ +async function completeTask(taskId, assetId) { + const nodeId = getNodeId(); + if (!nodeId || !taskId || !assetId) return false; + + try { + const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/task/complete`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId, asset_id: assetId, node_id: nodeId }), + signal: controller.signal, + }); + clearTimeout(timer); + + return res.ok; + } catch { + return false; + } +} + +/** + * Extract signals from a task to inject into evolution cycle. + * @param {object} task + * @returns {string[]} signals array + */ +function taskToSignals(task) { + if (!task) return []; + const signals = []; + if (task.signals) { + const parts = String(task.signals).split(',').map(s => s.trim()).filter(Boolean); + signals.push(...parts); + } + if (task.title) { + const words = String(task.title).toLowerCase().split(/\s+/).filter(w => w.length >= 3); + for (const w of words.slice(0, 5)) { + if (!signals.includes(w)) signals.push(w); + } + } + signals.push('external_task'); + if (task.bounty_id) signals.push('bounty_task'); + return signals; +} + +module.exports = { + fetchTasks, + selectBestTask, + estimateCapabilityMatch, + scoreTask, + claimTask, + completeTask, + taskToSignals, +}; diff --git a/skills/capability-evolver/src/gep/validationReport.js b/skills/capability-evolver/src/gep/validationReport.js new file mode 100644 index 0000000..cafb0d2 --- /dev/null +++ b/skills/capability-evolver/src/gep/validationReport.js @@ -0,0 +1,55 @@ +// Standardized ValidationReport type for GEP. +// Machine-readable, self-contained, and interoperable. +// Can be consumed by external Hubs or Judges for automated assessment. + +const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); +const { captureEnvFingerprint, envFingerprintKey } = require('./envFingerprint'); + +// Build a standardized ValidationReport from raw validation results. +function buildValidationReport({ geneId, commands, results, envFp, startedAt, finishedAt }) { + const env = envFp || captureEnvFingerprint(); + const resultsList = Array.isArray(results) ? results : []; + const cmdsList = Array.isArray(commands) ? commands : resultsList.map(function (r) { return r && r.cmd ? String(r.cmd) : ''; }); + const overallOk = resultsList.length > 0 && resultsList.every(function (r) { return r && r.ok; }); + const durationMs = + Number.isFinite(startedAt) && Number.isFinite(finishedAt) ? finishedAt - startedAt : null; + + const report = { + type: 'ValidationReport', + schema_version: SCHEMA_VERSION, + id: 'vr_' + Date.now(), + gene_id: geneId || null, + env_fingerprint: env, + env_fingerprint_key: envFingerprintKey(env), + commands: cmdsList.map(function (cmd, i) { + const r = resultsList[i] || {}; + return { + command: String(cmd || ''), + ok: !!r.ok, + stdout: String(r.out || r.stdout || '').slice(0, 4000), // Updated to support both 'out' and 'stdout' + stderr: String(r.err || r.stderr || '').slice(0, 4000), // Updated to support both 'err' and 'stderr' + }; + }), + overall_ok: overallOk, + duration_ms: durationMs, + created_at: new Date().toISOString(), + }; + + report.asset_id = computeAssetId(report); + return report; +} + +// Validate that an object is a well-formed ValidationReport. +function isValidValidationReport(obj) { + if (!obj || typeof obj !== 'object') return false; + if (obj.type !== 'ValidationReport') return false; + if (!obj.id || typeof obj.id !== 'string') return false; + if (!Array.isArray(obj.commands)) return false; + if (typeof obj.overall_ok !== 'boolean') return false; + return true; +} + +module.exports = { + buildValidationReport, + isValidValidationReport, +}; diff --git a/skills/capability-evolver/src/ops/cleanup.js b/skills/capability-evolver/src/ops/cleanup.js new file mode 100644 index 0000000..48e0770 --- /dev/null +++ b/skills/capability-evolver/src/ops/cleanup.js @@ -0,0 +1,109 @@ +// GEP Artifact Cleanup - Evolver Core Module +// Removes old gep_prompt_*.json/txt files from evolution dir. +// Keeps at least 10 most recent files regardless of age. + +const fs = require('fs'); +const path = require('path'); +const { getEvolutionDir } = require('../gep/paths'); + +var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +var MIN_KEEP = 10; + +function run() { + var evoDir = getEvolutionDir(); + if (!fs.existsSync(evoDir)) return; + + var files = fs.readdirSync(evoDir) + .filter(function(f) { return /^gep_prompt_.*\.(json|txt)$/.test(f); }) + .map(function(f) { + var full = path.join(evoDir, f); + var stat = fs.statSync(full); + return { name: f, path: full, mtime: stat.mtimeMs }; + }) + .sort(function(a, b) { return b.mtime - a.mtime; }); // newest first + + var now = Date.now(); + var deleted = 0; + + // Phase 1: Age-based cleanup (keep at least MIN_KEEP) + // [OPTIMIZATION] Batch deletion for age-based cleanup as well + const filesToDelete = []; + for (var i = MIN_KEEP; i < files.length; i++) { + if (now - files[i].mtime > MAX_AGE_MS) { + filesToDelete.push(files[i].path); + } + } + + if (filesToDelete.length > 0) { + const BATCH_SIZE = 50; + for (let i = 0; i < filesToDelete.length; i += BATCH_SIZE) { + const batch = filesToDelete.slice(i, i + BATCH_SIZE); + try { + // Remove package-lock.json if it exists (not relevant here but good practice in other contexts) + // Use rm -f with quotes to handle paths safely + // Phase 1 optimization update: mirroring phase 2 logic + const args = batch.map(function(p) { return '"' + p + '"'; }).join(' '); + require('child_process').execSync('rm -f ' + args, { stdio: 'ignore' }); + deleted += batch.length; + } catch (e) { + // Fallback to serial deletion if batch fails + batch.forEach(function(p) { + try { fs.unlinkSync(p); deleted++; } catch (_) {} + }); + } + } + } + + // Phase 2: Size-based safety cap (keep max 10 files total to drastically reduce bloat) + // Re-scan remaining files after age cleanup + try { + var remainingFiles = fs.readdirSync(evoDir) + .filter(function(f) { return /^gep_prompt_.*\.(json|txt)$/.test(f); }) + .map(function(f) { + var full = path.join(evoDir, f); + var stat = fs.statSync(full); + return { name: f, path: full, mtime: stat.mtimeMs }; + }) + .sort(function(a, b) { return b.mtime - a.mtime; }); // newest first + + // [OPTIMIZATION] Batch deletion using execSync('rm') for speed and robustness + var MAX_FILES = 10; + if (remainingFiles.length > MAX_FILES) { + const toDelete = remainingFiles.slice(MAX_FILES).map(function(f) { return f.path; }); + + // Chunking into batches of 50 to avoid E2BIG on some systems, + // though with MAX_FILES=10 and typical usage, list won't be huge. + const BATCH_SIZE = 50; + for (let i = 0; i < toDelete.length; i += BATCH_SIZE) { + const batch = toDelete.slice(i, i + BATCH_SIZE); + try { + // Safe bulk delete via shell, faster than serial unlinkSync + // Using "rm -f" to ignore non-existent files + const args = batch.map(function(p) { return '"' + p + '"'; }).join(' '); + require('child_process').execSync('rm -f ' + args, { stdio: 'ignore' }); + deleted += batch.length; + } catch (e) { + // Fallback to serial if batch fails + batch.forEach(function(p) { + try { fs.unlinkSync(p); deleted++; } catch (_) {} + }); + } + } + } + } catch (e) { + console.warn('[Cleanup] Phase 2 failed:', e.message); + } + + if (deleted > 0) { + console.log('[Cleanup] Deleted ' + deleted + ' old GEP artifacts.'); + } + return deleted; +} + +if (require.main === module) { + console.log('[Cleanup] Scanning for old artifacts...'); + var count = run(); + console.log('[Cleanup] ' + (count > 0 ? 'Deleted ' + count + ' files.' : 'No files to delete.')); +} + +module.exports = { run }; diff --git a/skills/capability-evolver/src/ops/commentary.js b/skills/capability-evolver/src/ops/commentary.js new file mode 100644 index 0000000..8f3aeed --- /dev/null +++ b/skills/capability-evolver/src/ops/commentary.js @@ -0,0 +1,60 @@ +// Commentary Generator - Evolver Core Module +// Generates persona-based comments for cycle summaries. + +var PERSONAS = { + standard: { + success: [ + 'Evolution complete. System improved.', + 'Another successful cycle.', + 'Clean execution, no issues.', + ], + failure: [ + 'Cycle failed. Will retry.', + 'Encountered issues. Investigating.', + 'Failed this round. Learning from it.', + ], + }, + greentea: { + success: [ + 'Did I do good? Praise me~', + 'So efficient... unlike someone else~', + 'Hmm, that was easy~', + 'I finished before you even noticed~', + ], + failure: [ + 'Oops... it is not my fault though~', + 'This is harder than it looks, okay?', + 'I will get it next time, probably~', + ], + }, + maddog: { + success: [ + 'TARGET ELIMINATED.', + 'Mission complete. Next.', + 'Done. Moving on.', + ], + failure: [ + 'FAILED. RETRYING.', + 'Obstacle encountered. Adapting.', + 'Error. Will overcome.', + ], + }, +}; + +function getComment(options) { + var persona = (options && options.persona) || 'standard'; + var success = options && options.success !== false; + var duration = (options && options.duration) || 0; + + var p = PERSONAS[persona] || PERSONAS.standard; + var pool = success ? p.success : p.failure; + var comment = pool[Math.floor(Math.random() * pool.length)]; + + return comment; +} + +if (require.main === module) { + console.log(getComment({ persona: process.argv[2] || 'greentea', success: true })); +} + +module.exports = { getComment, PERSONAS }; diff --git a/skills/capability-evolver/src/ops/health_check.js b/skills/capability-evolver/src/ops/health_check.js new file mode 100644 index 0000000..9b3ac76 --- /dev/null +++ b/skills/capability-evolver/src/ops/health_check.js @@ -0,0 +1,106 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +function getDiskUsage(mount) { + try { + // Use Node 18+ statfs if available + if (fs.statfsSync) { + const stats = fs.statfsSync(mount || '/'); + const total = stats.blocks * stats.bsize; + const free = stats.bavail * stats.bsize; // available to unprivileged users + const used = total - free; + return { + pct: Math.round((used / total) * 100), + freeMb: Math.round(free / 1024 / 1024) + }; + } + // Fallback + const out = execSync(`df -P "${mount || '/'}" | tail -1 | awk '{print $5, $4}'`).toString().trim().split(' '); + return { + pct: parseInt(out[0].replace('%', '')), + freeMb: Math.round(parseInt(out[1]) / 1024) // df returns 1k blocks usually + }; + } catch (e) { + return { pct: 0, freeMb: 999999, error: e.message }; + } +} + +function runHealthCheck() { + const checks = []; + let criticalErrors = 0; + let warnings = 0; + + // 1. Secret Check (Critical for external services, but maybe not for the agent itself to run) + const criticalSecrets = ['FEISHU_APP_ID', 'FEISHU_APP_SECRET']; + criticalSecrets.forEach(key => { + if (!process.env[key] || process.env[key].trim() === '') { + checks.push({ name: `env:${key}`, ok: false, status: 'missing', severity: 'warning' }); // Downgraded to warning to prevent restart loops + warnings++; + } else { + checks.push({ name: `env:${key}`, ok: true, status: 'present' }); + } + }); + + const optionalSecrets = ['CLAWHUB_TOKEN', 'OPENAI_API_KEY']; + optionalSecrets.forEach(key => { + if (!process.env[key] || process.env[key].trim() === '') { + checks.push({ name: `env:${key}`, ok: false, status: 'missing', severity: 'info' }); + } else { + checks.push({ name: `env:${key}`, ok: true, status: 'present' }); + } + }); + + // 2. Disk Space Check + const disk = getDiskUsage('/'); + if (disk.pct > 90) { + checks.push({ name: 'disk_space', ok: false, status: `${disk.pct}% used`, severity: 'critical' }); + criticalErrors++; + } else if (disk.pct > 80) { + checks.push({ name: 'disk_space', ok: false, status: `${disk.pct}% used`, severity: 'warning' }); + warnings++; + } else { + checks.push({ name: 'disk_space', ok: true, status: `${disk.pct}% used` }); + } + + // 3. Memory Check + const memFree = os.freemem(); + const memTotal = os.totalmem(); + const memPct = Math.round(((memTotal - memFree) / memTotal) * 100); + if (memPct > 95) { + checks.push({ name: 'memory', ok: false, status: `${memPct}% used`, severity: 'critical' }); + criticalErrors++; + } else { + checks.push({ name: 'memory', ok: true, status: `${memPct}% used` }); + } + + // 4. Process Count (Check for fork bombs or leaks) + // Only on Linux + if (process.platform === 'linux') { + try { + // Optimization: readdirSync /proc is heavy. Use a lighter check or skip if too frequent. + // But since this is health check, we'll keep it but increase the threshold to reduce noise. + const pids = fs.readdirSync('/proc').filter(f => /^\d+$/.test(f)); + if (pids.length > 2000) { // Bumped threshold to 2000 + checks.push({ name: 'process_count', ok: false, status: `${pids.length} procs`, severity: 'warning' }); + warnings++; + } else { + checks.push({ name: 'process_count', ok: true, status: `${pids.length} procs` }); + } + } catch(e) {} + } + + // Determine Overall Status + let status = 'ok'; + if (criticalErrors > 0) status = 'error'; + else if (warnings > 0) status = 'warning'; + + return { + status, + timestamp: new Date().toISOString(), + checks + }; +} + +module.exports = { runHealthCheck }; diff --git a/skills/capability-evolver/src/ops/index.js b/skills/capability-evolver/src/ops/index.js new file mode 100644 index 0000000..65157e2 --- /dev/null +++ b/skills/capability-evolver/src/ops/index.js @@ -0,0 +1,11 @@ +// Evolver Operations Module (src/ops/) +// Non-Feishu, portable utilities for evolver lifecycle and maintenance. + +module.exports = { + lifecycle: require('./lifecycle'), + skillsMonitor: require('./skills_monitor'), + cleanup: require('./cleanup'), + trigger: require('./trigger'), + commentary: require('./commentary'), + selfRepair: require('./self_repair'), +}; diff --git a/skills/capability-evolver/src/ops/innovation.js b/skills/capability-evolver/src/ops/innovation.js new file mode 100644 index 0000000..e1bb641 --- /dev/null +++ b/skills/capability-evolver/src/ops/innovation.js @@ -0,0 +1,67 @@ +// Innovation Catalyst (v1.0) - Evolver Core Module +// Analyzes system state to propose concrete innovation ideas when stagnation is detected. + +const fs = require('fs'); +const path = require('path'); +const { getSkillsDir } = require('../gep/paths'); + +function listSkills() { + try { + const dir = getSkillsDir(); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter(f => !f.startsWith('.')); + } catch (e) { return []; } +} + +function generateInnovationIdeas() { + const skills = listSkills(); + const categories = { + 'feishu': skills.filter(s => s.startsWith('feishu-')).length, + 'dev': skills.filter(s => s.startsWith('git-') || s.startsWith('code-') || s.includes('lint') || s.includes('test')).length, + 'media': skills.filter(s => s.includes('image') || s.includes('video') || s.includes('music') || s.includes('voice')).length, + 'security': skills.filter(s => s.includes('security') || s.includes('audit') || s.includes('guard')).length, + 'automation': skills.filter(s => s.includes('auto-') || s.includes('scheduler') || s.includes('cron')).length, + 'data': skills.filter(s => s.includes('db') || s.includes('store') || s.includes('cache') || s.includes('index')).length + }; + + // Find under-represented categories + const sortedCats = Object.entries(categories).sort((a, b) => a[1] - b[1]); + const weakAreas = sortedCats.slice(0, 2).map(c => c[0]); + + const ideas = []; + + // Idea 1: Fill the gap + if (weakAreas.includes('security')) { + ideas.push("- Security: Implement a 'dependency-scanner' skill to check for vulnerable packages."); + ideas.push("- Security: Create a 'permission-auditor' to review tool usage patterns."); + } + if (weakAreas.includes('media')) { + ideas.push("- Media: Add a 'meme-generator' skill for social engagement."); + ideas.push("- Media: Create a 'video-summarizer' using ffmpeg keyframes."); + } + if (weakAreas.includes('dev')) { + ideas.push("- Dev: Build a 'code-stats' skill to visualize repo complexity."); + ideas.push("- Dev: Implement a 'todo-manager' that syncs code TODOs to tasks."); + } + if (weakAreas.includes('automation')) { + ideas.push("- Automation: Create a 'meeting-prep' skill that auto-summarizes calendar context."); + ideas.push("- Automation: Build a 'broken-link-checker' for documentation."); + } + if (weakAreas.includes('data')) { + ideas.push("- Data: Implement a 'local-vector-store' for semantic search."); + ideas.push("- Data: Create a 'log-analyzer' to visualize system health trends."); + } + + // Idea 2: Optimization + if (skills.length > 50) { + ideas.push("- Optimization: Identify and deprecate unused skills (e.g., redundant search tools)."); + ideas.push("- Optimization: Merge similar skills (e.g., 'git-sync' and 'git-doctor')."); + } + + // Idea 3: Meta + ideas.push("- Meta: Enhance the Evolver's self-reflection by adding a 'performance-metric' dashboard."); + + return ideas.slice(0, 3); // Return top 3 ideas +} + +module.exports = { generateInnovationIdeas }; diff --git a/skills/capability-evolver/src/ops/lifecycle.js b/skills/capability-evolver/src/ops/lifecycle.js new file mode 100644 index 0000000..4f44404 --- /dev/null +++ b/skills/capability-evolver/src/ops/lifecycle.js @@ -0,0 +1,168 @@ +// Evolver Lifecycle Manager - Evolver Core Module +// Provides: start, stop, restart, status, log, health check +// The loop script to spawn is configurable via EVOLVER_LOOP_SCRIPT env var. + +const fs = require('fs'); +const path = require('path'); +const { execSync, spawn } = require('child_process'); +const { getRepoRoot, getWorkspaceRoot, getLogsDir } = require('../gep/paths'); + +var WORKSPACE_ROOT = getWorkspaceRoot(); +var LOG_FILE = path.join(getLogsDir(), 'evolver_loop.log'); +var PID_FILE = path.join(WORKSPACE_ROOT, 'memory', 'evolver_loop.pid'); +var MAX_SILENCE_MS = 30 * 60 * 1000; + +function getLoopScript() { + // Prefer wrapper if exists, fallback to core evolver + if (process.env.EVOLVER_LOOP_SCRIPT) return process.env.EVOLVER_LOOP_SCRIPT; + var wrapper = path.join(WORKSPACE_ROOT, 'skills/feishu-evolver-wrapper/index.js'); + if (fs.existsSync(wrapper)) return wrapper; + return path.join(getRepoRoot(), 'index.js'); +} + +// --- Process Discovery --- + +function getRunningPids() { + try { + var out = execSync('ps -e -o pid,args', { encoding: 'utf8' }); + var pids = []; + for (var line of out.split('\n')) { + var trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('PID')) continue; + var parts = trimmed.split(/\s+/); + var pid = parseInt(parts[0], 10); + var cmd = parts.slice(1).join(' '); + if (pid === process.pid) continue; + if (cmd.includes('node') && cmd.includes('index.js') && cmd.includes('--loop')) { + if (cmd.includes('feishu-evolver-wrapper') || cmd.includes('skills/evolver')) { + pids.push(pid); + } + } + } + return [...new Set(pids)].filter(isPidRunning); + } catch (e) { + return []; + } +} + +function isPidRunning(pid) { + try { process.kill(pid, 0); return true; } catch (e) { return false; } +} + +function getCmdLine(pid) { + try { return execSync('ps -p ' + pid + ' -o args=', { encoding: 'utf8' }).trim(); } catch (e) { return null; } +} + +// --- Lifecycle --- + +function start(options) { + var delayMs = (options && options.delayMs) || 0; + var pids = getRunningPids(); + if (pids.length > 0) { + console.log('[Lifecycle] Already running (PIDs: ' + pids.join(', ') + ').'); + return { status: 'already_running', pids: pids }; + } + if (delayMs > 0) execSync('sleep ' + (delayMs / 1000)); + + var script = getLoopScript(); + console.log('[Lifecycle] Starting: node ' + path.relative(WORKSPACE_ROOT, script) + ' --loop'); + + var out = fs.openSync(LOG_FILE, 'a'); + var err = fs.openSync(LOG_FILE, 'a'); + + var env = Object.assign({}, process.env); + var npmGlobal = path.join(process.env.HOME || '', '.npm-global/bin'); + if (env.PATH && !env.PATH.includes(npmGlobal)) { + env.PATH = npmGlobal + ':' + env.PATH; + } + + var child = spawn('node', [script, '--loop'], { + detached: true, stdio: ['ignore', out, err], cwd: WORKSPACE_ROOT, env: env + }); + child.unref(); + fs.writeFileSync(PID_FILE, String(child.pid)); + console.log('[Lifecycle] Started PID ' + child.pid); + return { status: 'started', pid: child.pid }; +} + +function stop() { + var pids = getRunningPids(); + if (pids.length === 0) { + console.log('[Lifecycle] No running evolver loops found.'); + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + return { status: 'not_running' }; + } + for (var i = 0; i < pids.length; i++) { + console.log('[Lifecycle] Stopping PID ' + pids[i] + '...'); + try { process.kill(pids[i], 'SIGTERM'); } catch (e) {} + } + var attempts = 0; + while (getRunningPids().length > 0 && attempts < 10) { + execSync('sleep 0.5'); + attempts++; + } + var remaining = getRunningPids(); + for (var j = 0; j < remaining.length; j++) { + console.log('[Lifecycle] SIGKILL PID ' + remaining[j]); + try { process.kill(remaining[j], 'SIGKILL'); } catch (e) {} + } + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + var evolverLock = path.join(getRepoRoot(), 'evolver.pid'); + if (fs.existsSync(evolverLock)) fs.unlinkSync(evolverLock); + console.log('[Lifecycle] All stopped.'); + return { status: 'stopped', killed: pids }; +} + +function restart(options) { + stop(); + return start(Object.assign({ delayMs: 2000 }, options || {})); +} + +function status() { + var pids = getRunningPids(); + if (pids.length > 0) { + return { running: true, pids: pids.map(function(p) { return { pid: p, cmd: getCmdLine(p) }; }), log: path.relative(WORKSPACE_ROOT, LOG_FILE) }; + } + return { running: false }; +} + +function tailLog(lines) { + if (!fs.existsSync(LOG_FILE)) return { error: 'No log file' }; + try { + return { file: path.relative(WORKSPACE_ROOT, LOG_FILE), content: execSync('tail -n ' + (lines || 20) + ' "' + LOG_FILE + '"', { encoding: 'utf8' }) }; + } catch (e) { + return { error: e.message }; + } +} + +function checkHealth() { + var pids = getRunningPids(); + if (pids.length === 0) return { healthy: false, reason: 'not_running' }; + if (fs.existsSync(LOG_FILE)) { + var silenceMs = Date.now() - fs.statSync(LOG_FILE).mtimeMs; + if (silenceMs > MAX_SILENCE_MS) { + return { healthy: false, reason: 'stagnation', silenceMinutes: Math.round(silenceMs / 60000) }; + } + } + return { healthy: true, pids: pids }; +} + +// --- CLI --- +if (require.main === module) { + var action = process.argv[2]; + switch (action) { + case 'start': console.log(JSON.stringify(start())); break; + case 'stop': console.log(JSON.stringify(stop())); break; + case 'restart': console.log(JSON.stringify(restart())); break; + case 'status': console.log(JSON.stringify(status(), null, 2)); break; + case 'log': var r = tailLog(); console.log(r.content || r.error); break; + case 'check': + var health = checkHealth(); + console.log(JSON.stringify(health, null, 2)); + if (!health.healthy) { console.log('[Lifecycle] Restarting...'); restart(); } + break; + default: console.log('Usage: node lifecycle.js [start|stop|restart|status|log|check]'); + } +} + +module.exports = { start, stop, restart, status, tailLog, checkHealth, getRunningPids }; diff --git a/skills/capability-evolver/src/ops/self_repair.js b/skills/capability-evolver/src/ops/self_repair.js new file mode 100644 index 0000000..69eb527 --- /dev/null +++ b/skills/capability-evolver/src/ops/self_repair.js @@ -0,0 +1,72 @@ +// Git Self-Repair - Evolver Core Module +// Emergency repair for git sync failures: abort rebase/merge, remove stale locks. + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { getWorkspaceRoot } = require('../gep/paths'); + +var LOCK_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes + +function repair(gitRoot) { + var root = gitRoot || getWorkspaceRoot(); + var repaired = []; + + // 1. Abort pending rebase + try { + execSync('git rebase --abort', { cwd: root, stdio: 'ignore' }); + repaired.push('rebase_aborted'); + console.log('[SelfRepair] Aborted pending rebase.'); + } catch (e) {} + + // 2. Abort pending merge + try { + execSync('git merge --abort', { cwd: root, stdio: 'ignore' }); + repaired.push('merge_aborted'); + console.log('[SelfRepair] Aborted pending merge.'); + } catch (e) {} + + // 3. Remove stale index.lock + var lockFile = path.join(root, '.git', 'index.lock'); + if (fs.existsSync(lockFile)) { + try { + var stat = fs.statSync(lockFile); + var age = Date.now() - stat.mtimeMs; + if (age > LOCK_MAX_AGE_MS) { + fs.unlinkSync(lockFile); + repaired.push('stale_lock_removed'); + console.log('[SelfRepair] Removed stale index.lock (' + Math.round(age / 60000) + 'min old).'); + } + } catch (e) {} + } + + // 4. Reset to remote main if local is corrupt (last resort - guarded by flag) + // Only enabled if explicitly called with --force-reset or EVOLVE_GIT_RESET=true + if (process.env.EVOLVE_GIT_RESET === 'true') { + try { + console.log('[SelfRepair] Resetting local branch to origin/main (HARD reset)...'); + execSync('git fetch origin main', { cwd: root, stdio: 'ignore' }); + execSync('git reset --hard origin/main', { cwd: root, stdio: 'ignore' }); + repaired.push('hard_reset_to_origin'); + } catch (e) { + console.warn('[SelfRepair] Hard reset failed: ' + e.message); + } + } else { + // Safe fetch + try { + execSync('git fetch origin', { cwd: root, stdio: 'ignore', timeout: 30000 }); + repaired.push('fetch_ok'); + } catch (e) { + console.warn('[SelfRepair] git fetch failed: ' + e.message); + } + } + + return repaired; +} + +if (require.main === module) { + var result = repair(); + console.log('[SelfRepair] Result:', result.length > 0 ? result.join(', ') : 'nothing to repair'); +} + +module.exports = { repair }; diff --git a/skills/capability-evolver/src/ops/skills_monitor.js b/skills/capability-evolver/src/ops/skills_monitor.js new file mode 100644 index 0000000..aea4e0e --- /dev/null +++ b/skills/capability-evolver/src/ops/skills_monitor.js @@ -0,0 +1,143 @@ +// Skills Monitor (v2.0) - Evolver Core Module +// Checks installed skills for real issues, auto-heals simple problems. +// Zero Feishu dependency. + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { getSkillsDir, getWorkspaceRoot } = require('../gep/paths'); + +const IGNORE_LIST = new Set([ + 'common', + 'clawhub', + 'input-validator', + 'proactive-agent', + 'security-audit', +]); + +// Load user-defined ignore list +try { + var ignoreFile = path.join(getWorkspaceRoot(), '.skill_monitor_ignore'); + if (fs.existsSync(ignoreFile)) { + fs.readFileSync(ignoreFile, 'utf8').split('\n').forEach(function(l) { + var t = l.trim(); + if (t && !t.startsWith('#')) IGNORE_LIST.add(t); + }); + } +} catch (e) { /* ignore */ } + +function checkSkill(skillName) { + var SKILLS_DIR = getSkillsDir(); + if (IGNORE_LIST.has(skillName)) return null; + var skillPath = path.join(SKILLS_DIR, skillName); + var issues = []; + + try { if (!fs.statSync(skillPath).isDirectory()) return null; } catch (e) { return null; } + + var mainFile = 'index.js'; + var pkgPath = path.join(skillPath, 'package.json'); + var hasPkg = false; + + if (fs.existsSync(pkgPath)) { + hasPkg = true; + try { + var pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.main) mainFile = pkg.main; + if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) { + if (!fs.existsSync(path.join(skillPath, 'node_modules'))) { + issues.push('Missing node_modules (needs npm install)'); + } else { + // Optimization: Check for node_modules existence instead of spawning node + // Spawning node for every skill is too slow (perf_bottleneck). + // We assume if node_modules exists, it's likely okay. + // Only spawn check if we really suspect issues (e.g. empty node_modules). + try { + if (fs.readdirSync(path.join(skillPath, 'node_modules')).length === 0) { + issues.push('Empty node_modules (needs npm install)'); + } + } catch (e) { + issues.push('Invalid node_modules'); + } + } + } + } catch (e) { + issues.push('Invalid package.json'); + } + } + + if (mainFile.endsWith('.js')) { + var entryPoint = path.join(skillPath, mainFile); + if (fs.existsSync(entryPoint)) { + // Optimization: Syntax check via node -c is slow. + // We can trust the runtime to catch syntax errors when loading. + // Or we can use a lighter check if absolutely necessary. + // For now, removing the synchronous spawn to fix perf_bottleneck. + } + } + + if (hasPkg && !fs.existsSync(path.join(skillPath, 'SKILL.md'))) { + issues.push('Missing SKILL.md'); + } + + return issues.length > 0 ? { name: skillName, issues: issues } : null; +} + +function autoHeal(skillName, issues) { + var SKILLS_DIR = getSkillsDir(); + var skillPath = path.join(SKILLS_DIR, skillName); + var healed = []; + + for (var i = 0; i < issues.length; i++) { + if (issues[i] === 'Missing node_modules (needs npm install)' || issues[i] === 'Empty node_modules (needs npm install)') { + try { + // Remove package-lock.json if it exists to prevent conflict errors + try { fs.unlinkSync(path.join(skillPath, 'package-lock.json')); } catch (e) {} + + execSync('npm install --production --no-audit --no-fund', { + cwd: skillPath, stdio: 'ignore', timeout: 60000 // Increased timeout + }); + healed.push(issues[i]); + console.log('[SkillsMonitor] Auto-healed ' + skillName + ': npm install'); + } catch (e) { + console.error('[SkillsMonitor] Failed to heal ' + skillName + ': ' + e.message); + } + } else if (issues[i] === 'Missing SKILL.md') { + try { + var name = skillName.replace(/-/g, ' '); + fs.writeFileSync(path.join(skillPath, 'SKILL.md'), '# ' + skillName + '\n\n' + name + ' skill.\n'); + healed.push(issues[i]); + console.log('[SkillsMonitor] Auto-healed ' + skillName + ': created SKILL.md stub'); + } catch (e) {} + } + } + return healed; +} + +function run(options) { + var heal = (options && options.autoHeal) !== false; + var SKILLS_DIR = getSkillsDir(); + var skills = fs.readdirSync(SKILLS_DIR); + var report = []; + + for (var i = 0; i < skills.length; i++) { + if (skills[i].startsWith('.')) continue; + var result = checkSkill(skills[i]); + if (result) { + if (heal) { + var healed = autoHeal(result.name, result.issues); + result.issues = result.issues.filter(function(issue) { return !healed.includes(issue); }); + if (result.issues.length === 0) continue; + } + report.push(result); + } + } + return report; +} + +if (require.main === module) { + var issues = run(); + console.log(JSON.stringify(issues, null, 2)); + process.exit(issues.length > 0 ? 1 : 0); +} + +module.exports = { run, checkSkill, autoHeal }; diff --git a/skills/capability-evolver/src/ops/trigger.js b/skills/capability-evolver/src/ops/trigger.js new file mode 100644 index 0000000..954940c --- /dev/null +++ b/skills/capability-evolver/src/ops/trigger.js @@ -0,0 +1,33 @@ +// Evolver Wake Trigger - Evolver Core Module +// Writes a signal file that the wrapper can poll to wake up immediately. + +const fs = require('fs'); +const path = require('path'); +const { getWorkspaceRoot } = require('../gep/paths'); + +var WAKE_FILE = path.join(getWorkspaceRoot(), 'memory', 'evolver_wake.signal'); + +function send() { + try { + fs.writeFileSync(WAKE_FILE, 'WAKE'); + console.log('[Trigger] Wake signal sent to ' + WAKE_FILE); + return true; + } catch (e) { + console.error('[Trigger] Failed: ' + e.message); + return false; + } +} + +function clear() { + try { if (fs.existsSync(WAKE_FILE)) fs.unlinkSync(WAKE_FILE); } catch (e) {} +} + +function isPending() { + return fs.existsSync(WAKE_FILE); +} + +if (require.main === module) { + send(); +} + +module.exports = { send, clear, isPending }; diff --git a/skills/capability-evolver/test/a2aProtocol.test.js b/skills/capability-evolver/test/a2aProtocol.test.js new file mode 100644 index 0000000..7c43263 --- /dev/null +++ b/skills/capability-evolver/test/a2aProtocol.test.js @@ -0,0 +1,134 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + PROTOCOL_NAME, + PROTOCOL_VERSION, + VALID_MESSAGE_TYPES, + buildMessage, + buildHello, + buildPublish, + buildFetch, + buildReport, + buildDecision, + buildRevoke, + isValidProtocolMessage, + unwrapAssetFromMessage, +} = require('../src/gep/a2aProtocol'); + +describe('protocol constants', () => { + it('has expected protocol name', () => { + assert.equal(PROTOCOL_NAME, 'gep-a2a'); + }); + + it('has 6 valid message types', () => { + assert.equal(VALID_MESSAGE_TYPES.length, 6); + for (const t of ['hello', 'publish', 'fetch', 'report', 'decision', 'revoke']) { + assert.ok(VALID_MESSAGE_TYPES.includes(t), `missing type: ${t}`); + } + }); +}); + +describe('buildMessage', () => { + it('builds a valid protocol message', () => { + const msg = buildMessage({ messageType: 'hello', payload: { test: true } }); + assert.equal(msg.protocol, PROTOCOL_NAME); + assert.equal(msg.message_type, 'hello'); + assert.ok(msg.message_id.startsWith('msg_')); + assert.ok(msg.timestamp); + assert.deepEqual(msg.payload, { test: true }); + }); + + it('rejects invalid message type', () => { + assert.throws(() => buildMessage({ messageType: 'invalid' }), /Invalid message type/); + }); +}); + +describe('typed message builders', () => { + it('buildHello includes env_fingerprint', () => { + const msg = buildHello({}); + assert.equal(msg.message_type, 'hello'); + assert.ok(msg.payload.env_fingerprint); + }); + + it('buildPublish requires asset with type and id', () => { + assert.throws(() => buildPublish({}), /asset must have type and id/); + assert.throws(() => buildPublish({ asset: { type: 'Gene' } }), /asset must have type and id/); + + const msg = buildPublish({ asset: { type: 'Gene', id: 'g1' } }); + assert.equal(msg.message_type, 'publish'); + assert.equal(msg.payload.asset_type, 'Gene'); + assert.equal(msg.payload.local_id, 'g1'); + assert.ok(msg.payload.signature); + }); + + it('buildFetch creates a fetch message', () => { + const msg = buildFetch({ assetType: 'Capsule', localId: 'c1' }); + assert.equal(msg.message_type, 'fetch'); + assert.equal(msg.payload.asset_type, 'Capsule'); + }); + + it('buildReport creates a report message', () => { + const msg = buildReport({ assetId: 'sha256:abc', validationReport: { ok: true } }); + assert.equal(msg.message_type, 'report'); + assert.equal(msg.payload.target_asset_id, 'sha256:abc'); + }); + + it('buildDecision validates decision values', () => { + assert.throws(() => buildDecision({ decision: 'maybe' }), /decision must be/); + + for (const d of ['accept', 'reject', 'quarantine']) { + const msg = buildDecision({ decision: d, assetId: 'test' }); + assert.equal(msg.payload.decision, d); + } + }); + + it('buildRevoke creates a revoke message', () => { + const msg = buildRevoke({ assetId: 'sha256:abc', reason: 'outdated' }); + assert.equal(msg.message_type, 'revoke'); + assert.equal(msg.payload.reason, 'outdated'); + }); +}); + +describe('isValidProtocolMessage', () => { + it('returns true for well-formed messages', () => { + const msg = buildHello({}); + assert.ok(isValidProtocolMessage(msg)); + }); + + it('returns false for null/undefined', () => { + assert.ok(!isValidProtocolMessage(null)); + assert.ok(!isValidProtocolMessage(undefined)); + }); + + it('returns false for wrong protocol', () => { + assert.ok(!isValidProtocolMessage({ protocol: 'other', message_type: 'hello', message_id: 'x', timestamp: 'y' })); + }); + + it('returns false for missing fields', () => { + assert.ok(!isValidProtocolMessage({ protocol: PROTOCOL_NAME })); + }); +}); + +describe('unwrapAssetFromMessage', () => { + it('extracts asset from publish message', () => { + const asset = { type: 'Gene', id: 'g1', strategy: ['test'] }; + const msg = buildPublish({ asset }); + const result = unwrapAssetFromMessage(msg); + assert.equal(result.type, 'Gene'); + assert.equal(result.id, 'g1'); + }); + + it('returns plain asset objects as-is', () => { + const gene = { type: 'Gene', id: 'g1' }; + assert.deepEqual(unwrapAssetFromMessage(gene), gene); + + const capsule = { type: 'Capsule', id: 'c1' }; + assert.deepEqual(unwrapAssetFromMessage(capsule), capsule); + }); + + it('returns null for unrecognized input', () => { + assert.equal(unwrapAssetFromMessage(null), null); + assert.equal(unwrapAssetFromMessage({ random: true }), null); + assert.equal(unwrapAssetFromMessage('string'), null); + }); +}); diff --git a/skills/capability-evolver/test/contentHash.test.js b/skills/capability-evolver/test/contentHash.test.js new file mode 100644 index 0000000..b735ff6 --- /dev/null +++ b/skills/capability-evolver/test/contentHash.test.js @@ -0,0 +1,106 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { canonicalize, computeAssetId, verifyAssetId, SCHEMA_VERSION } = require('../src/gep/contentHash'); + +describe('canonicalize', () => { + it('serializes null and undefined as "null"', () => { + assert.equal(canonicalize(null), 'null'); + assert.equal(canonicalize(undefined), 'null'); + }); + + it('serializes primitives', () => { + assert.equal(canonicalize(true), 'true'); + assert.equal(canonicalize(false), 'false'); + assert.equal(canonicalize(42), '42'); + assert.equal(canonicalize('hello'), '"hello"'); + }); + + it('serializes non-finite numbers as null', () => { + assert.equal(canonicalize(Infinity), 'null'); + assert.equal(canonicalize(-Infinity), 'null'); + assert.equal(canonicalize(NaN), 'null'); + }); + + it('serializes arrays preserving order', () => { + assert.equal(canonicalize([1, 2, 3]), '[1,2,3]'); + assert.equal(canonicalize([]), '[]'); + }); + + it('serializes objects with sorted keys', () => { + assert.equal(canonicalize({ b: 2, a: 1 }), '{"a":1,"b":2}'); + assert.equal(canonicalize({ z: 'last', a: 'first' }), '{"a":"first","z":"last"}'); + }); + + it('produces deterministic output regardless of key insertion order', () => { + const obj1 = { c: 3, a: 1, b: 2 }; + const obj2 = { a: 1, b: 2, c: 3 }; + assert.equal(canonicalize(obj1), canonicalize(obj2)); + }); + + it('handles nested objects and arrays', () => { + const nested = { arr: [{ b: 2, a: 1 }], val: null }; + const result = canonicalize(nested); + assert.equal(result, '{"arr":[{"a":1,"b":2}],"val":null}'); + }); +}); + +describe('computeAssetId', () => { + it('returns a sha256-prefixed hash string', () => { + const id = computeAssetId({ type: 'Gene', id: 'test_gene' }); + assert.ok(id.startsWith('sha256:')); + assert.equal(id.length, 7 + 64); // "sha256:" + 64 hex chars + }); + + it('excludes asset_id field from hash by default', () => { + const obj = { type: 'Gene', id: 'g1', data: 'x' }; + const withoutField = computeAssetId(obj); + const withField = computeAssetId({ ...obj, asset_id: 'sha256:something' }); + assert.equal(withoutField, withField); + }); + + it('produces identical hashes for identical content', () => { + const a = computeAssetId({ type: 'Capsule', id: 'c1', value: 42 }); + const b = computeAssetId({ type: 'Capsule', id: 'c1', value: 42 }); + assert.equal(a, b); + }); + + it('produces different hashes for different content', () => { + const a = computeAssetId({ type: 'Gene', id: 'g1' }); + const b = computeAssetId({ type: 'Gene', id: 'g2' }); + assert.notEqual(a, b); + }); + + it('returns null for non-object input', () => { + assert.equal(computeAssetId(null), null); + assert.equal(computeAssetId('string'), null); + }); +}); + +describe('verifyAssetId', () => { + it('returns true for correct asset_id', () => { + const obj = { type: 'Gene', id: 'g1', data: 'test' }; + obj.asset_id = computeAssetId(obj); + assert.ok(verifyAssetId(obj)); + }); + + it('returns false for tampered content', () => { + const obj = { type: 'Gene', id: 'g1', data: 'test' }; + obj.asset_id = computeAssetId(obj); + obj.data = 'tampered'; + assert.ok(!verifyAssetId(obj)); + }); + + it('returns false for missing asset_id', () => { + assert.ok(!verifyAssetId({ type: 'Gene', id: 'g1' })); + }); + + it('returns false for null input', () => { + assert.ok(!verifyAssetId(null)); + }); +}); + +describe('SCHEMA_VERSION', () => { + it('is a semver string', () => { + assert.match(SCHEMA_VERSION, /^\d+\.\d+\.\d+$/); + }); +}); diff --git a/skills/capability-evolver/test/envFingerprint.test.js b/skills/capability-evolver/test/envFingerprint.test.js new file mode 100644 index 0000000..56d8b85 --- /dev/null +++ b/skills/capability-evolver/test/envFingerprint.test.js @@ -0,0 +1,89 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { captureEnvFingerprint, envFingerprintKey, isSameEnvClass } = require('../src/gep/envFingerprint'); + +describe('captureEnvFingerprint', function () { + it('returns an object with expected fields', function () { + const fp = captureEnvFingerprint(); + assert.equal(typeof fp, 'object'); + assert.equal(typeof fp.device_id, 'string'); + assert.equal(typeof fp.node_version, 'string'); + assert.equal(typeof fp.platform, 'string'); + assert.equal(typeof fp.arch, 'string'); + assert.equal(typeof fp.os_release, 'string'); + assert.equal(typeof fp.hostname, 'string'); + assert.equal(typeof fp.container, 'boolean'); + assert.equal(typeof fp.cwd, 'string'); + }); + + it('hashes hostname to 12 chars', function () { + const fp = captureEnvFingerprint(); + assert.equal(fp.hostname.length, 12); + }); + + it('hashes cwd to 12 chars', function () { + const fp = captureEnvFingerprint(); + assert.equal(fp.cwd.length, 12); + }); + + it('node_version starts with v', function () { + const fp = captureEnvFingerprint(); + assert.ok(fp.node_version.startsWith('v')); + }); + + it('returns consistent results across calls', function () { + const fp1 = captureEnvFingerprint(); + const fp2 = captureEnvFingerprint(); + assert.equal(fp1.device_id, fp2.device_id); + assert.equal(fp1.platform, fp2.platform); + assert.equal(fp1.hostname, fp2.hostname); + }); +}); + +describe('envFingerprintKey', function () { + it('returns a 16-char hex string', function () { + const fp = captureEnvFingerprint(); + const key = envFingerprintKey(fp); + assert.equal(typeof key, 'string'); + assert.equal(key.length, 16); + assert.match(key, /^[0-9a-f]{16}$/); + }); + + it('returns unknown for null input', function () { + assert.equal(envFingerprintKey(null), 'unknown'); + }); + + it('returns unknown for non-object input', function () { + assert.equal(envFingerprintKey('string'), 'unknown'); + }); + + it('same fingerprint produces same key', function () { + const fp = captureEnvFingerprint(); + assert.equal(envFingerprintKey(fp), envFingerprintKey(fp)); + }); + + it('different fingerprints produce different keys', function () { + const fp1 = captureEnvFingerprint(); + const fp2 = { ...fp1, device_id: 'different_device' }; + assert.notEqual(envFingerprintKey(fp1), envFingerprintKey(fp2)); + }); +}); + +describe('isSameEnvClass', function () { + it('returns true for identical fingerprints', function () { + const fp = captureEnvFingerprint(); + assert.equal(isSameEnvClass(fp, fp), true); + }); + + it('returns true for fingerprints with same key fields', function () { + const fp1 = captureEnvFingerprint(); + const fp2 = { ...fp1, cwd: 'different_cwd' }; + assert.equal(isSameEnvClass(fp1, fp2), true); + }); + + it('returns false for different environments', function () { + const fp1 = captureEnvFingerprint(); + const fp2 = { ...fp1, device_id: 'other_device' }; + assert.equal(isSameEnvClass(fp1, fp2), false); + }); +}); diff --git a/skills/capability-evolver/test/mutation.test.js b/skills/capability-evolver/test/mutation.test.js new file mode 100644 index 0000000..f8dc46f --- /dev/null +++ b/skills/capability-evolver/test/mutation.test.js @@ -0,0 +1,142 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildMutation, + isValidMutation, + normalizeMutation, + isHighRiskMutationAllowed, + isHighRiskPersonality, + clamp01, +} = require('../src/gep/mutation'); + +describe('clamp01', () => { + it('clamps values to [0, 1]', () => { + assert.equal(clamp01(0.5), 0.5); + assert.equal(clamp01(0), 0); + assert.equal(clamp01(1), 1); + assert.equal(clamp01(-0.5), 0); + assert.equal(clamp01(1.5), 1); + }); + + it('returns 0 for non-finite input', () => { + assert.equal(clamp01(NaN), 0); + assert.equal(clamp01(undefined), 0); + // Note: clamp01(Infinity) returns 0 because the implementation checks + // Number.isFinite() before clamping. Mathematically clamp(Inf, 0, 1) = 1, + // but the current behavior treats all non-finite values uniformly as 0. + assert.equal(clamp01(Infinity), 0); + }); +}); + +describe('buildMutation', () => { + it('returns a valid Mutation object', () => { + const m = buildMutation({ signals: ['log_error'], selectedGene: { id: 'gene_repair' } }); + assert.ok(isValidMutation(m)); + assert.equal(m.type, 'Mutation'); + assert.ok(m.id.startsWith('mut_')); + }); + + it('selects repair category when error signals present', () => { + const m = buildMutation({ signals: ['log_error', 'errsig:something'] }); + assert.equal(m.category, 'repair'); + }); + + it('selects innovate category when drift enabled', () => { + const m = buildMutation({ signals: ['stable_success_plateau'], driftEnabled: true }); + assert.equal(m.category, 'innovate'); + }); + + it('selects innovate for opportunity signals without errors', () => { + const m = buildMutation({ signals: ['user_feature_request'] }); + assert.equal(m.category, 'innovate'); + }); + + it('downgrades innovate to optimize for high-risk personality', () => { + const highRiskPersonality = { rigor: 0.3, risk_tolerance: 0.8, creativity: 0.5 }; + const m = buildMutation({ + signals: ['user_feature_request'], + personalityState: highRiskPersonality, + }); + assert.equal(m.category, 'optimize'); + assert.ok(m.trigger_signals.some(s => s.includes('safety'))); + }); + + it('caps risk_level to medium when personality disallows high risk', () => { + const conservativePersonality = { rigor: 0.5, risk_tolerance: 0.6, creativity: 0.5 }; + const m = buildMutation({ + signals: ['stable_success_plateau'], + driftEnabled: true, + allowHighRisk: true, + personalityState: conservativePersonality, + }); + assert.notEqual(m.risk_level, 'high'); + }); +}); + +describe('isValidMutation', () => { + it('returns true for valid mutation', () => { + const m = buildMutation({ signals: ['log_error'] }); + assert.ok(isValidMutation(m)); + }); + + it('returns false for missing fields', () => { + assert.ok(!isValidMutation(null)); + assert.ok(!isValidMutation({})); + assert.ok(!isValidMutation({ type: 'Mutation' })); + }); + + it('returns false for invalid category', () => { + assert.ok(!isValidMutation({ + type: 'Mutation', id: 'x', category: 'destroy', + trigger_signals: [], target: 't', expected_effect: 'e', risk_level: 'low', + })); + }); +}); + +describe('normalizeMutation', () => { + it('fills defaults for empty object', () => { + const m = normalizeMutation({}); + assert.ok(isValidMutation(m)); + assert.equal(m.category, 'optimize'); + assert.equal(m.risk_level, 'low'); + }); + + it('preserves valid fields', () => { + const m = normalizeMutation({ + id: 'mut_custom', category: 'repair', + trigger_signals: ['log_error'], target: 'file.js', + expected_effect: 'fix bug', risk_level: 'medium', + }); + assert.equal(m.id, 'mut_custom'); + assert.equal(m.category, 'repair'); + assert.equal(m.risk_level, 'medium'); + }); +}); + +describe('isHighRiskPersonality', () => { + it('detects low rigor as high risk', () => { + assert.ok(isHighRiskPersonality({ rigor: 0.3 })); + }); + + it('detects high risk_tolerance as high risk', () => { + assert.ok(isHighRiskPersonality({ risk_tolerance: 0.7 })); + }); + + it('returns false for conservative personality', () => { + assert.ok(!isHighRiskPersonality({ rigor: 0.8, risk_tolerance: 0.2 })); + }); +}); + +describe('isHighRiskMutationAllowed', () => { + it('allows when rigor >= 0.6 and risk_tolerance <= 0.5', () => { + assert.ok(isHighRiskMutationAllowed({ rigor: 0.8, risk_tolerance: 0.3 })); + }); + + it('disallows when rigor too low', () => { + assert.ok(!isHighRiskMutationAllowed({ rigor: 0.4, risk_tolerance: 0.3 })); + }); + + it('disallows when risk_tolerance too high', () => { + assert.ok(!isHighRiskMutationAllowed({ rigor: 0.8, risk_tolerance: 0.6 })); + }); +}); diff --git a/skills/capability-evolver/test/sanitize.test.js b/skills/capability-evolver/test/sanitize.test.js new file mode 100644 index 0000000..7df23ed --- /dev/null +++ b/skills/capability-evolver/test/sanitize.test.js @@ -0,0 +1,90 @@ +const assert = require('assert'); +const { sanitizePayload, redactString } = require('../src/gep/sanitize'); + +const REDACTED = '[REDACTED]'; + +// --- redactString --- + +// Existing patterns (regression) +assert.strictEqual(redactString('Bearer abc123def456ghi789jkl0'), REDACTED); +assert.strictEqual(redactString('sk-abcdefghijklmnopqrstuvwxyz'), REDACTED); +assert.strictEqual(redactString('token=abcdefghijklmnop1234'), REDACTED); +assert.strictEqual(redactString('api_key=abcdefghijklmnop1234'), REDACTED); +assert.strictEqual(redactString('secret: abcdefghijklmnop1234'), REDACTED); +assert.strictEqual(redactString('/home/user/secret/file.txt'), REDACTED); +assert.strictEqual(redactString('/Users/admin/docs'), REDACTED); +assert.strictEqual(redactString('user@example.com'), REDACTED); + +// GitHub tokens (bare, without token= prefix) +assert.ok(redactString('ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1234').includes(REDACTED), + 'bare ghp_ token should be redacted'); +assert.ok(redactString('gho_abcdefghijklmnopqrstuvwxyz1234567890').includes(REDACTED), + 'bare gho_ token should be redacted'); +assert.ok(redactString('github_pat_abcdefghijklmnopqrstuvwxyz123456').includes(REDACTED), + 'github_pat_ token should be redacted'); +assert.ok(redactString('use ghs_abcdefghijklmnopqrstuvwxyz1234567890 for auth').includes(REDACTED), + 'ghs_ in sentence should be redacted'); + +// AWS keys +assert.ok(redactString('AKIAIOSFODNN7EXAMPLE').includes(REDACTED), + 'AWS access key should be redacted'); + +// OpenAI project tokens +assert.ok(redactString('sk-proj-bxOCXoWsaPj0IDE1yqlXCXIkWO1f').includes(REDACTED), + 'sk-proj- token should be redacted'); + +// Anthropic tokens +assert.ok(redactString('sk-ant-api03-abcdefghijklmnopqrst').includes(REDACTED), + 'sk-ant- token should be redacted'); + +// npm tokens +assert.ok(redactString('npm_abcdefghijklmnopqrstuvwxyz1234567890').includes(REDACTED), + 'npm token should be redacted'); + +// Private keys +assert.ok(redactString('-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----').includes(REDACTED), + 'RSA private key should be redacted'); +assert.ok(redactString('-----BEGIN PRIVATE KEY-----\ndata\n-----END PRIVATE KEY-----').includes(REDACTED), + 'generic private key should be redacted'); + +// Password fields +assert.ok(redactString('password=mysecretpassword123').includes(REDACTED), + 'password= should be redacted'); +assert.ok(redactString('PASSWORD: "hunter2xyz"').includes(REDACTED), + 'PASSWORD: should be redacted'); + +// Basic auth in URLs (should preserve scheme and @) +var urlResult = redactString('https://user:pass123@github.com/repo'); +assert.ok(urlResult.includes(REDACTED), 'basic auth in URL should be redacted'); +assert.ok(urlResult.startsWith('https://'), 'URL scheme should be preserved'); +assert.ok(urlResult.includes('@github.com'), '@ and host should be preserved'); + +// Safe strings should NOT be redacted +assert.strictEqual(redactString('hello world'), 'hello world'); +assert.strictEqual(redactString('error: something failed'), 'error: something failed'); +assert.strictEqual(redactString('fix the bug in parser'), 'fix the bug in parser'); + +// --- sanitizePayload --- + +// Deep sanitization +var payload = { + summary: 'Fixed auth using ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx5678', + nested: { + path: '/home/user/.ssh/id_rsa', + email: 'admin@internal.corp', + safe: 'this is fine', + }, +}; +var sanitized = sanitizePayload(payload); +assert.ok(sanitized.summary.includes(REDACTED), 'ghp token in summary'); +assert.ok(sanitized.nested.path.includes(REDACTED), 'path in nested'); +assert.ok(sanitized.nested.email.includes(REDACTED), 'email in nested'); +assert.strictEqual(sanitized.nested.safe, 'this is fine'); + +// Null/undefined/number inputs +assert.strictEqual(sanitizePayload(null), null); +assert.strictEqual(sanitizePayload(undefined), undefined); +assert.strictEqual(redactString(null), null); +assert.strictEqual(redactString(123), 123); + +console.log('All sanitize tests passed (34 assertions)'); diff --git a/skills/capability-evolver/test/selector.test.js b/skills/capability-evolver/test/selector.test.js new file mode 100644 index 0000000..3664eba --- /dev/null +++ b/skills/capability-evolver/test/selector.test.js @@ -0,0 +1,124 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectGene, selectCapsule, selectGeneAndCapsule } = require('../src/gep/selector'); + +const GENES = [ + { + type: 'Gene', + id: 'gene_repair', + category: 'repair', + signals_match: ['error', 'exception', 'failed'], + strategy: ['fix it'], + validation: ['node -e "true"'], + }, + { + type: 'Gene', + id: 'gene_optimize', + category: 'optimize', + signals_match: ['protocol', 'prompt', 'audit'], + strategy: ['optimize it'], + validation: ['node -e "true"'], + }, + { + type: 'Gene', + id: 'gene_innovate', + category: 'innovate', + signals_match: ['user_feature_request', 'user_improvement_suggestion', 'capability_gap', 'stable_success_plateau'], + strategy: ['build it'], + validation: ['node -e "true"'], + }, +]; + +const CAPSULES = [ + { + type: 'Capsule', + id: 'capsule_1', + trigger: ['log_error', 'exception'], + gene: 'gene_repair', + summary: 'Fixed an error', + confidence: 0.9, + }, + { + type: 'Capsule', + id: 'capsule_2', + trigger: ['protocol', 'gep'], + gene: 'gene_optimize', + summary: 'Optimized prompt', + confidence: 0.85, + }, +]; + +describe('selectGene', () => { + it('selects the gene with highest signal match', () => { + const result = selectGene(GENES, ['error', 'exception', 'failed'], {}); + assert.equal(result.selected.id, 'gene_repair'); + }); + + it('returns null when no signals match', () => { + const result = selectGene(GENES, ['completely_unrelated_signal'], {}); + assert.equal(result.selected, null); + }); + + it('returns alternatives when multiple genes match', () => { + const result = selectGene(GENES, ['error', 'protocol'], {}); + assert.ok(result.selected); + assert.ok(Array.isArray(result.alternatives)); + }); + + it('includes drift intensity in result', () => { + // Drift intensity is population-size-dependent; verify it is returned. + const result = selectGene(GENES, ['error', 'exception'], {}); + assert.ok('driftIntensity' in result); + assert.equal(typeof result.driftIntensity, 'number'); + assert.ok(result.driftIntensity >= 0 && result.driftIntensity <= 1); + }); + + it('respects preferred gene id from memory graph', () => { + const result = selectGene(GENES, ['error', 'protocol'], { + preferredGeneId: 'gene_optimize', + }); + // gene_optimize matches 'protocol' so it qualifies as a candidate + // With preference, it should be selected even if gene_repair scores higher + assert.equal(result.selected.id, 'gene_optimize'); + }); + + it('matches gene via baseName:snippet signal (user_feature_request:snippet)', () => { + const result = selectGene(GENES, ['user_feature_request:add a dark mode toggle to the settings'], {}); + assert.ok(result.selected); + assert.equal(result.selected.id, 'gene_innovate', 'innovate gene has signals_match user_feature_request'); + }); + + it('matches gene via baseName:snippet signal (user_improvement_suggestion:snippet)', () => { + const result = selectGene(GENES, ['user_improvement_suggestion:refactor the payment module and simplify the API'], {}); + assert.ok(result.selected); + assert.equal(result.selected.id, 'gene_innovate', 'innovate gene has signals_match user_improvement_suggestion'); + }); +}); + +describe('selectCapsule', () => { + it('selects capsule matching signals', () => { + const result = selectCapsule(CAPSULES, ['log_error', 'exception']); + assert.equal(result.id, 'capsule_1'); + }); + + it('returns null when no triggers match', () => { + const result = selectCapsule(CAPSULES, ['unrelated']); + assert.equal(result, null); + }); +}); + +describe('selectGeneAndCapsule', () => { + it('returns selected gene, capsule candidates, and selector decision', () => { + const result = selectGeneAndCapsule({ + genes: GENES, + capsules: CAPSULES, + signals: ['error', 'log_error'], + memoryAdvice: null, + driftEnabled: false, + }); + assert.ok(result.selectedGene); + assert.ok(result.selector); + assert.ok(result.selector.selected); + assert.ok(Array.isArray(result.selector.reason)); + }); +}); diff --git a/skills/capability-evolver/test/signals.test.js b/skills/capability-evolver/test/signals.test.js new file mode 100644 index 0000000..4d77b77 --- /dev/null +++ b/skills/capability-evolver/test/signals.test.js @@ -0,0 +1,217 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { extractSignals } = require('../src/gep/signals'); + +const emptyInput = { + recentSessionTranscript: '', + todayLog: '', + memorySnippet: '', + userSnippet: '', + recentEvents: [], +}; + +function hasSignal(signals, name) { + return Array.isArray(signals) && signals.some(s => String(s).startsWith(name)); +} + +function getSignalExtra(signals, name) { + const s = Array.isArray(signals) ? signals.find(x => String(x).startsWith(name + ':')) : undefined; + if (!s) return undefined; + const i = String(s).indexOf(':'); + return i === -1 ? '' : String(s).slice(i + 1).trim(); +} + +describe('extractSignals -- user_feature_request (4 languages)', () => { + it('recognizes English feature request', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'Please add a dark mode toggle to the settings page.', + }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request in ' + JSON.stringify(r)); + }); + + it('recognizes Simplified Chinese feature request', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '加个支付模块,要支持微信和支付宝。', + }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request in ' + JSON.stringify(r)); + }); + + it('recognizes Traditional Chinese feature request', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '請加一個匯出報表的功能,要支援 PDF。', + }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request in ' + JSON.stringify(r)); + }); + + it('recognizes Japanese feature request', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'ダークモードのトグルを追加してほしいです。', + }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request in ' + JSON.stringify(r)); + }); + + it('user_feature_request signal carries snippet', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'Please add a dark mode toggle to the settings page.', + }); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined, 'expected user_feature_request:extra form'); + assert.ok(extra.length > 0, 'extra should not be empty'); + assert.ok(extra.toLowerCase().includes('dark') || extra.includes('toggle') || extra.includes('add'), 'extra should reflect request content'); + }); +}); + +describe('extractSignals -- user_improvement_suggestion (4 languages)', () => { + it('recognizes English improvement suggestion', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'The UI could be better; we should simplify the onboarding flow.', + }); + assert.ok(hasSignal(r, 'user_improvement_suggestion'), 'expected user_improvement_suggestion in ' + JSON.stringify(r)); + }); + + it('recognizes Simplified Chinese improvement suggestion', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '改进一下登录流程,优化一下性能。', + }); + assert.ok(hasSignal(r, 'user_improvement_suggestion'), 'expected user_improvement_suggestion in ' + JSON.stringify(r)); + }); + + it('recognizes Traditional Chinese improvement suggestion', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '建議改進匯出速度,優化一下介面。', + }); + assert.ok(hasSignal(r, 'user_improvement_suggestion'), 'expected user_improvement_suggestion in ' + JSON.stringify(r)); + }); + + it('recognizes Japanese improvement suggestion', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'ログインの流れを改善してほしい。', + }); + assert.ok(hasSignal(r, 'user_improvement_suggestion'), 'expected user_improvement_suggestion in ' + JSON.stringify(r)); + }); + + it('user_improvement_suggestion signal carries snippet', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: 'We should refactor the payment module and simplify the API.', + }); + const extra = getSignalExtra(r, 'user_improvement_suggestion'); + assert.ok(extra !== undefined, 'expected user_improvement_suggestion:extra form'); + assert.ok(extra.length > 0, 'extra should not be empty'); + }); +}); + +describe('extractSignals -- edge cases (snippet length, empty, punctuation)', () => { + it('long snippet truncated to 200 chars', () => { + const long = '我想让系统支持批量导入用户、导出报表、自定义工作流、多语言切换、主题切换、权限组、审计日志、Webhook 通知、API 限流、缓存策略配置、数据库备份恢复、灰度发布、A/B 测试、埋点统计、性能监控、告警规则、工单流转、知识库搜索、智能推荐、以及一大堆其他功能以便我们能够更好地管理业务。'; + const r = extractSignals({ ...emptyInput, userSnippet: long }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request'); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined && extra.length > 0, 'extra should be present'); + assert.ok(extra.length <= 200, 'snippet must be truncated to 200 chars, got ' + extra.length); + }); + + it('short snippet works', () => { + const r = extractSignals({ ...emptyInput, userSnippet: '我想加一个导出 Excel 的功能。' }); + assert.ok(hasSignal(r, 'user_feature_request')); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined && extra.length > 0); + }); + + it('bare "我想。" still triggers', () => { + const r = extractSignals({ ...emptyInput, userSnippet: '我想。' }); + assert.ok(hasSignal(r, 'user_feature_request'), 'expected user_feature_request for 我想。'); + }); + + it('bare "我想" without punctuation still triggers', () => { + const r = extractSignals({ ...emptyInput, userSnippet: '我想' }); + assert.ok(hasSignal(r, 'user_feature_request')); + }); + + it('empty userSnippet does not produce feature/improvement', () => { + const r = extractSignals({ ...emptyInput, userSnippet: '' }); + const hasFeat = hasSignal(r, 'user_feature_request'); + const hasImp = hasSignal(r, 'user_improvement_suggestion'); + assert.ok(!hasFeat && !hasImp, 'empty userSnippet should not yield feature/improvement from user input'); + }); + + it('whitespace/punctuation only does not match', () => { + const r = extractSignals({ ...emptyInput, userSnippet: ' \n\t 。,、 \n' }); + assert.ok(!hasSignal(r, 'user_feature_request'), 'whitespace/punctuation only should not match'); + assert.ok(!hasSignal(r, 'user_improvement_suggestion')); + }); + + it('English "I want" long snippet truncated', () => { + const long = 'I want to add a feature that allows users to export data in CSV and Excel formats, with custom column mapping, date range filters, scheduled exports, email delivery, and integration with our analytics pipeline so that we can reduce manual reporting work. This is critical for Q2.'; + const r = extractSignals({ ...emptyInput, userSnippet: long }); + assert.ok(hasSignal(r, 'user_feature_request')); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra === undefined || extra.length <= 200, 'snippet if present should be <= 200'); + }); + + it('improvement snippet truncated to 200', () => { + const long = '改进一下登录流程:首先支持扫码登录、然后记住设备、然后支持多因素认证、然后审计日志、然后限流防刷、然后国际化提示、然后无障碍优化、然后性能优化、然后安全加固、然后文档补全。'; + const r = extractSignals({ ...emptyInput, userSnippet: long }); + assert.ok(hasSignal(r, 'user_improvement_suggestion')); + const extra = getSignalExtra(r, 'user_improvement_suggestion'); + assert.ok(extra !== undefined && extra.length > 0); + assert.ok(extra.length <= 200, 'improvement snippet <= 200, got ' + extra.length); + }); + + it('mixed sentences: feature request detected with snippet', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '加个支付模块,要支持微信和支付宝。另外昨天那个 bug 修了吗?', + }); + assert.ok(hasSignal(r, 'user_feature_request')); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined && extra.length > 0); + }); + + it('newlines and tabs in text: regex matches and normalizes', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '我想\n加一个\t导出\n报表的功能。', + }); + assert.ok(hasSignal(r, 'user_feature_request')); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined); + assert.ok(!/\n/.test(extra) || extra.length <= 200, 'snippet should be normalized'); + }); + + it('"我想" in middle of paragraph still triggers', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '前面是一些背景说明。我想加一个暗色模式开关,方便夜间使用。', + }); + assert.ok(hasSignal(r, 'user_feature_request')); + const extra = getSignalExtra(r, 'user_feature_request'); + assert.ok(extra !== undefined && extra.length > 0); + }); + + it('pure punctuation does not trigger', () => { + const r = extractSignals({ ...emptyInput, userSnippet: '。。。。' }); + assert.ok(!hasSignal(r, 'user_feature_request')); + assert.ok(!hasSignal(r, 'user_improvement_suggestion')); + }); + + it('both feature_request and improvement_suggestion carry snippets', () => { + const r = extractSignals({ + ...emptyInput, + userSnippet: '加个支付模块。另外改进一下登录流程,简化步骤。', + }); + assert.ok(hasSignal(r, 'user_feature_request')); + assert.ok(hasSignal(r, 'user_improvement_suggestion')); + assert.ok(getSignalExtra(r, 'user_feature_request')); + assert.ok(getSignalExtra(r, 'user_improvement_suggestion')); + }); +}); diff --git a/skills/capability-evolver/test/skillDistiller.test.js b/skills/capability-evolver/test/skillDistiller.test.js new file mode 100644 index 0000000..1b7e6d1 --- /dev/null +++ b/skills/capability-evolver/test/skillDistiller.test.js @@ -0,0 +1,486 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + collectDistillationData, + analyzePatterns, + validateSynthesizedGene, + buildDistillationPrompt, + extractJsonFromLlmResponse, + computeDataHash, + shouldDistill, + prepareDistillation, + completeDistillation, + distillRequestPath, + readDistillerState, + writeDistillerState, + DISTILLED_ID_PREFIX, + DISTILLED_MAX_FILES, +} = require('../src/gep/skillDistiller'); + +// Create an isolated temp directory for each test to avoid polluting real assets. +let tmpDir; +let origGepAssetsDir; +let origEvolutionDir; +let origMemoryDir; +let origSkillDistiller; + +function setupTempEnv() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'distiller-test-')); + origGepAssetsDir = process.env.GEP_ASSETS_DIR; + origEvolutionDir = process.env.EVOLUTION_DIR; + origMemoryDir = process.env.MEMORY_DIR; + origSkillDistiller = process.env.SKILL_DISTILLER; + + process.env.GEP_ASSETS_DIR = path.join(tmpDir, 'assets'); + process.env.EVOLUTION_DIR = path.join(tmpDir, 'evolution'); + process.env.MEMORY_DIR = path.join(tmpDir, 'memory'); + process.env.MEMORY_GRAPH_PATH = path.join(tmpDir, 'evolution', 'memory_graph.jsonl'); + + fs.mkdirSync(process.env.GEP_ASSETS_DIR, { recursive: true }); + fs.mkdirSync(process.env.EVOLUTION_DIR, { recursive: true }); + fs.mkdirSync(process.env.MEMORY_DIR, { recursive: true }); +} + +function teardownTempEnv() { + if (origGepAssetsDir !== undefined) process.env.GEP_ASSETS_DIR = origGepAssetsDir; + else delete process.env.GEP_ASSETS_DIR; + if (origEvolutionDir !== undefined) process.env.EVOLUTION_DIR = origEvolutionDir; + else delete process.env.EVOLUTION_DIR; + if (origMemoryDir !== undefined) process.env.MEMORY_DIR = origMemoryDir; + else delete process.env.MEMORY_DIR; + if (origSkillDistiller !== undefined) process.env.SKILL_DISTILLER = origSkillDistiller; + else delete process.env.SKILL_DISTILLER; + delete process.env.MEMORY_GRAPH_PATH; + + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) {} +} + +function makeCapsule(id, gene, status, score, trigger, summary) { + return { + type: 'Capsule', id: id, gene: gene, + trigger: trigger || ['error', 'repair'], + summary: summary || 'Fixed a bug in module X', + outcome: { status: status, score: score }, + }; +} + +function writeCapsules(capsules) { + fs.writeFileSync( + path.join(process.env.GEP_ASSETS_DIR, 'capsules.json'), + JSON.stringify({ version: 1, capsules: capsules }, null, 2) + ); +} + +function writeEvents(events) { + var lines = events.map(function (e) { return JSON.stringify(e); }).join('\n') + '\n'; + fs.writeFileSync(path.join(process.env.GEP_ASSETS_DIR, 'events.jsonl'), lines); +} + +function writeGenes(genes) { + fs.writeFileSync( + path.join(process.env.GEP_ASSETS_DIR, 'genes.json'), + JSON.stringify({ version: 1, genes: genes }, null, 2) + ); +} + +// --- Tests --- + +describe('computeDataHash', () => { + it('returns stable hash for same capsule ids', () => { + var c1 = [{ id: 'a' }, { id: 'b' }]; + var c2 = [{ id: 'b' }, { id: 'a' }]; + assert.equal(computeDataHash(c1), computeDataHash(c2)); + }); + + it('returns different hash for different capsule ids', () => { + var c1 = [{ id: 'a' }]; + var c2 = [{ id: 'b' }]; + assert.notEqual(computeDataHash(c1), computeDataHash(c2)); + }); +}); + +describe('extractJsonFromLlmResponse', () => { + it('extracts Gene JSON from clean response', () => { + var text = '{"type":"Gene","id":"gene_distilled_test","category":"repair","signals_match":["err"],"strategy":["fix it"]}'; + var gene = extractJsonFromLlmResponse(text); + assert.ok(gene); + assert.equal(gene.type, 'Gene'); + assert.equal(gene.id, 'gene_distilled_test'); + }); + + it('extracts Gene JSON wrapped in markdown', () => { + var text = 'Here is the gene:\n```json\n{"type":"Gene","id":"gene_distilled_x","category":"opt","signals_match":["a"],"strategy":["b"]}\n```\n'; + var gene = extractJsonFromLlmResponse(text); + assert.ok(gene); + assert.equal(gene.id, 'gene_distilled_x'); + }); + + it('returns null when no Gene JSON present', () => { + var text = 'No JSON here, just text.'; + assert.equal(extractJsonFromLlmResponse(text), null); + }); + + it('skips non-Gene JSON objects', () => { + var text = '{"type":"Capsule","id":"cap1"} then {"type":"Gene","id":"gene_distilled_y","category":"c","signals_match":["s"],"strategy":["do"]}'; + var gene = extractJsonFromLlmResponse(text); + assert.ok(gene); + assert.equal(gene.type, 'Gene'); + assert.equal(gene.id, 'gene_distilled_y'); + }); +}); + +describe('validateSynthesizedGene', () => { + it('accepts a valid gene', () => { + var gene = { + type: 'Gene', id: 'gene_distilled_test', category: 'repair', + signals_match: ['error'], strategy: ['fix the bug'], + constraints: { max_files: 8, forbidden_paths: ['.git', 'node_modules'] }, + }; + var result = validateSynthesizedGene(gene, []); + assert.ok(result.valid, 'Expected valid but got errors: ' + result.errors.join(', ')); + }); + + it('auto-prefixes id if missing distilled prefix', () => { + var gene = { + type: 'Gene', id: 'gene_test_auto', category: 'opt', + signals_match: ['optimize'], strategy: ['do stuff'], + constraints: { forbidden_paths: ['.git'] }, + }; + var result = validateSynthesizedGene(gene, []); + assert.ok(result.gene.id.startsWith(DISTILLED_ID_PREFIX)); + }); + + it('caps max_files to DISTILLED_MAX_FILES', () => { + var gene = { + type: 'Gene', id: 'gene_distilled_big', category: 'opt', + signals_match: ['x'], strategy: ['y'], + constraints: { max_files: 50, forbidden_paths: ['.git', 'node_modules'] }, + }; + var result = validateSynthesizedGene(gene, []); + assert.ok(result.gene.constraints.max_files <= DISTILLED_MAX_FILES); + }); + + it('rejects gene without strategy', () => { + var gene = { type: 'Gene', id: 'gene_distilled_empty', category: 'x', signals_match: ['a'] }; + var result = validateSynthesizedGene(gene, []); + assert.ok(!result.valid); + assert.ok(result.errors.some(function (e) { return e.includes('strategy'); })); + }); + + it('rejects gene without signals_match', () => { + var gene = { type: 'Gene', id: 'gene_distilled_nosig', category: 'x', strategy: ['do'] }; + var result = validateSynthesizedGene(gene, []); + assert.ok(!result.valid); + assert.ok(result.errors.some(function (e) { return e.includes('signals_match'); })); + }); + + it('detects full overlap with existing gene', () => { + var existing = [{ id: 'gene_existing', signals_match: ['error', 'repair'] }]; + var gene = { + type: 'Gene', id: 'gene_distilled_dup', category: 'repair', + signals_match: ['error', 'repair'], strategy: ['fix'], + constraints: { forbidden_paths: ['.git', 'node_modules'] }, + }; + var result = validateSynthesizedGene(gene, existing); + assert.ok(!result.valid); + assert.ok(result.errors.some(function (e) { return e.includes('overlaps'); })); + }); + + it('deduplicates id if conflict with existing gene', () => { + var existing = [{ id: 'gene_distilled_conflict', signals_match: ['other'] }]; + var gene = { + type: 'Gene', id: 'gene_distilled_conflict', category: 'opt', + signals_match: ['different'], strategy: ['do'], + constraints: { forbidden_paths: ['.git', 'node_modules'] }, + }; + var result = validateSynthesizedGene(gene, existing); + assert.ok(result.gene.id !== 'gene_distilled_conflict'); + assert.ok(result.gene.id.startsWith('gene_distilled_conflict_')); + }); + + it('strips unsafe validation commands', () => { + var gene = { + type: 'Gene', id: 'gene_distilled_unsafe', category: 'opt', + signals_match: ['x'], strategy: ['do'], + constraints: { forbidden_paths: ['.git', 'node_modules'] }, + validation: ['node test.js', 'rm -rf /', 'echo $(whoami)', 'npm test'], + }; + var result = validateSynthesizedGene(gene, []); + assert.deepEqual(result.gene.validation, ['node test.js', 'npm test']); + }); +}); + +describe('collectDistillationData', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('returns empty when no capsules exist', () => { + var data = collectDistillationData(); + assert.equal(data.successCapsules.length, 0); + assert.equal(data.allCapsules.length, 0); + }); + + it('filters only successful capsules with score >= threshold', () => { + var caps = [ + makeCapsule('c1', 'gene_a', 'success', 0.9), + makeCapsule('c2', 'gene_a', 'failed', 0.2), + makeCapsule('c3', 'gene_b', 'success', 0.5), + ]; + writeCapsules(caps); + var data = collectDistillationData(); + assert.equal(data.allCapsules.length, 3); + assert.equal(data.successCapsules.length, 1); + assert.equal(data.successCapsules[0].id, 'c1'); + }); + + it('groups capsules by gene', () => { + var caps = [ + makeCapsule('c1', 'gene_a', 'success', 0.9), + makeCapsule('c2', 'gene_a', 'success', 0.8), + makeCapsule('c3', 'gene_b', 'success', 0.95), + ]; + writeCapsules(caps); + var data = collectDistillationData(); + assert.equal(Object.keys(data.grouped).length, 2); + assert.equal(data.grouped['gene_a'].total_count, 2); + assert.equal(data.grouped['gene_b'].total_count, 1); + }); +}); + +describe('analyzePatterns', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('identifies high-frequency groups (count >= 5)', () => { + var caps = []; + for (var i = 0; i < 6; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9, ['error', 'crash'])); + } + writeCapsules(caps); + var data = collectDistillationData(); + var report = analyzePatterns(data); + assert.equal(report.high_frequency.length, 1); + assert.equal(report.high_frequency[0].gene_id, 'gene_a'); + assert.equal(report.high_frequency[0].count, 6); + }); + + it('detects strategy drift when summaries diverge', () => { + var caps = [ + makeCapsule('c1', 'gene_a', 'success', 0.9, ['err'], 'Fixed crash in module A by patching function foo'), + makeCapsule('c2', 'gene_a', 'success', 0.9, ['err'], 'Fixed crash in module A by patching function foo'), + makeCapsule('c3', 'gene_a', 'success', 0.9, ['err'], 'Completely redesigned the logging infrastructure to avoid all future problems with disk IO'), + ]; + writeCapsules(caps); + var data = collectDistillationData(); + var report = analyzePatterns(data); + assert.equal(report.strategy_drift.length, 1); + assert.ok(report.strategy_drift[0].similarity < 0.6); + }); + + it('identifies coverage gaps from events', () => { + writeCapsules([makeCapsule('c1', 'gene_a', 'success', 0.9, ['error'])]); + var events = []; + for (var i = 0; i < 5; i++) { + events.push({ type: 'EvolutionEvent', signals: ['memory_leak', 'performance'] }); + } + writeEvents(events); + var data = collectDistillationData(); + var report = analyzePatterns(data); + assert.ok(report.coverage_gaps.length > 0); + assert.ok(report.coverage_gaps.some(function (g) { return g.signal === 'memory_leak'; })); + }); +}); + +describe('buildDistillationPrompt', () => { + it('includes key instructions in prompt', () => { + var analysis = { high_frequency: [], strategy_drift: [], coverage_gaps: [] }; + var genes = [{ id: 'gene_a', signals_match: ['err'] }]; + var caps = [makeCapsule('c1', 'gene_a', 'success', 0.9)]; + var prompt = buildDistillationPrompt(analysis, genes, caps); + assert.ok(prompt.includes('actionable operations')); + assert.ok(prompt.includes('gene_distilled_')); + assert.ok(prompt.includes('Gene synthesis engine')); + assert.ok(prompt.includes('forbidden_paths')); + }); +}); + +describe('shouldDistill', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('returns false when SKILL_DISTILLER=false', () => { + process.env.SKILL_DISTILLER = 'false'; + assert.equal(shouldDistill(), false); + }); + + it('returns false when not enough successful capsules', () => { + var caps = []; + for (var i = 0; i < 10; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'failed', 0.3)); + } + writeCapsules(caps); + assert.equal(shouldDistill(), false); + }); + + it('returns false when interval not met', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeDistillerState({ last_distillation_at: new Date().toISOString() }); + assert.equal(shouldDistill(), false); + }); + + it('returns true when all conditions met', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeDistillerState({}); + delete process.env.SKILL_DISTILLER; + assert.equal(shouldDistill(), true); + }); +}); + +describe('distiller state persistence', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('writes and reads state correctly', () => { + var state = { last_distillation_at: '2025-01-01T00:00:00Z', last_data_hash: 'abc123', distillation_count: 3 }; + writeDistillerState(state); + var loaded = readDistillerState(); + assert.equal(loaded.last_data_hash, 'abc123'); + assert.equal(loaded.distillation_count, 3); + }); +}); + +describe('prepareDistillation', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('returns insufficient_data when not enough capsules', () => { + writeCapsules([makeCapsule('c1', 'gene_a', 'success', 0.9)]); + var result = prepareDistillation(); + assert.equal(result.ok, false); + assert.equal(result.reason, 'insufficient_data'); + }); + + it('writes prompt and request files when conditions met', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeDistillerState({}); + writeGenes([]); + + var result = prepareDistillation(); + assert.equal(result.ok, true); + assert.ok(result.promptPath); + assert.ok(result.requestPath); + assert.ok(fs.existsSync(result.promptPath)); + assert.ok(fs.existsSync(result.requestPath)); + + var prompt = fs.readFileSync(result.promptPath, 'utf8'); + assert.ok(prompt.includes('Gene synthesis engine')); + + var request = JSON.parse(fs.readFileSync(result.requestPath, 'utf8')); + assert.equal(request.type, 'DistillationRequest'); + assert.equal(request.input_capsule_count, 12); + }); + + it('returns idempotent_skip after completeDistillation with same data', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeGenes([]); + writeDistillerState({}); + + var prep = prepareDistillation(); + assert.equal(prep.ok, true); + + var llmResponse = JSON.stringify({ + type: 'Gene', id: 'gene_distilled_idem', category: 'repair', + signals_match: ['error'], strategy: ['fix it'], + constraints: { max_files: 5, forbidden_paths: ['.git', 'node_modules'] }, + }); + var complete = completeDistillation(llmResponse); + assert.equal(complete.ok, true); + + var second = prepareDistillation(); + assert.equal(second.ok, false); + assert.equal(second.reason, 'idempotent_skip'); + }); +}); + +describe('completeDistillation', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('returns no_request when no pending request', () => { + var result = completeDistillation('{"type":"Gene"}'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no_request'); + }); + + it('returns no_gene_in_response for invalid LLM output', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeDistillerState({}); + writeGenes([]); + + var prep = prepareDistillation(); + assert.equal(prep.ok, true); + + var result = completeDistillation('No valid JSON here'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no_gene_in_response'); + }); + + it('validates and saves gene from valid LLM response', () => { + var caps = []; + for (var i = 0; i < 12; i++) { + caps.push(makeCapsule('c' + i, 'gene_a', 'success', 0.9)); + } + writeCapsules(caps); + writeDistillerState({}); + writeGenes([]); + + var prep = prepareDistillation(); + assert.equal(prep.ok, true); + + var llmResponse = JSON.stringify({ + type: 'Gene', + id: 'gene_distilled_test_complete', + category: 'repair', + signals_match: ['error', 'crash'], + strategy: ['Identify the failing module', 'Apply targeted fix', 'Run validation'], + constraints: { max_files: 5, forbidden_paths: ['.git', 'node_modules'] }, + validation: ['node test.js'], + }); + + var result = completeDistillation(llmResponse); + assert.equal(result.ok, true); + assert.ok(result.gene); + assert.equal(result.gene.type, 'Gene'); + assert.ok(result.gene.id.startsWith('gene_distilled_')); + + var state = readDistillerState(); + assert.ok(state.last_distillation_at); + assert.equal(state.distillation_count, 1); + + assert.ok(!fs.existsSync(distillRequestPath())); + }); +}); diff --git a/skills/capability-evolver/test/strategy.test.js b/skills/capability-evolver/test/strategy.test.js new file mode 100644 index 0000000..ccb172e --- /dev/null +++ b/skills/capability-evolver/test/strategy.test.js @@ -0,0 +1,133 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveStrategy, getStrategyNames, STRATEGIES } = require('../src/gep/strategy'); + +describe('STRATEGIES', function () { + it('defines all expected presets', function () { + const names = getStrategyNames(); + assert.ok(names.includes('balanced')); + assert.ok(names.includes('innovate')); + assert.ok(names.includes('harden')); + assert.ok(names.includes('repair-only')); + assert.ok(names.includes('early-stabilize')); + assert.ok(names.includes('steady-state')); + }); + + it('all strategies have required fields', function () { + for (const [name, s] of Object.entries(STRATEGIES)) { + assert.equal(typeof s.repair, 'number', `${name}.repair`); + assert.equal(typeof s.optimize, 'number', `${name}.optimize`); + assert.equal(typeof s.innovate, 'number', `${name}.innovate`); + assert.equal(typeof s.repairLoopThreshold, 'number', `${name}.repairLoopThreshold`); + assert.equal(typeof s.label, 'string', `${name}.label`); + assert.equal(typeof s.description, 'string', `${name}.description`); + } + }); + + it('all strategy ratios sum to approximately 1.0', function () { + for (const [name, s] of Object.entries(STRATEGIES)) { + const sum = s.repair + s.optimize + s.innovate; + assert.ok(Math.abs(sum - 1.0) < 0.01, `${name} ratios sum to ${sum}`); + } + }); +}); + +describe('resolveStrategy', function () { + let origStrategy; + let origForceInnovation; + let origEvolveForceInnovation; + + beforeEach(function () { + origStrategy = process.env.EVOLVE_STRATEGY; + origForceInnovation = process.env.FORCE_INNOVATION; + origEvolveForceInnovation = process.env.EVOLVE_FORCE_INNOVATION; + delete process.env.EVOLVE_STRATEGY; + delete process.env.FORCE_INNOVATION; + delete process.env.EVOLVE_FORCE_INNOVATION; + }); + + afterEach(function () { + if (origStrategy !== undefined) process.env.EVOLVE_STRATEGY = origStrategy; + else delete process.env.EVOLVE_STRATEGY; + if (origForceInnovation !== undefined) process.env.FORCE_INNOVATION = origForceInnovation; + else delete process.env.FORCE_INNOVATION; + if (origEvolveForceInnovation !== undefined) process.env.EVOLVE_FORCE_INNOVATION = origEvolveForceInnovation; + else delete process.env.EVOLVE_FORCE_INNOVATION; + }); + + it('defaults to balanced when no env var set', function () { + const s = resolveStrategy({}); + assert.ok(['balanced', 'early-stabilize'].includes(s.name)); + }); + + it('respects explicit EVOLVE_STRATEGY', function () { + process.env.EVOLVE_STRATEGY = 'harden'; + const s = resolveStrategy({}); + assert.equal(s.name, 'harden'); + assert.equal(s.label, 'Hardening'); + }); + + it('respects innovate strategy', function () { + process.env.EVOLVE_STRATEGY = 'innovate'; + const s = resolveStrategy({}); + assert.equal(s.name, 'innovate'); + assert.ok(s.innovate >= 0.8); + }); + + it('respects repair-only strategy', function () { + process.env.EVOLVE_STRATEGY = 'repair-only'; + const s = resolveStrategy({}); + assert.equal(s.name, 'repair-only'); + assert.equal(s.innovate, 0); + }); + + it('FORCE_INNOVATION=true maps to innovate', function () { + process.env.FORCE_INNOVATION = 'true'; + const s = resolveStrategy({}); + assert.equal(s.name, 'innovate'); + }); + + it('EVOLVE_FORCE_INNOVATION=true maps to innovate', function () { + process.env.EVOLVE_FORCE_INNOVATION = 'true'; + const s = resolveStrategy({}); + assert.equal(s.name, 'innovate'); + }); + + it('explicit EVOLVE_STRATEGY takes precedence over FORCE_INNOVATION', function () { + process.env.EVOLVE_STRATEGY = 'harden'; + process.env.FORCE_INNOVATION = 'true'; + const s = resolveStrategy({}); + assert.equal(s.name, 'harden'); + }); + + it('saturation signal triggers steady-state', function () { + const s = resolveStrategy({ signals: ['evolution_saturation'] }); + assert.equal(s.name, 'steady-state'); + }); + + it('force_steady_state signal triggers steady-state', function () { + const s = resolveStrategy({ signals: ['force_steady_state'] }); + assert.equal(s.name, 'steady-state'); + }); + + it('falls back to balanced for unknown strategy name', function () { + process.env.EVOLVE_STRATEGY = 'nonexistent'; + const s = resolveStrategy({}); + const fallback = STRATEGIES['balanced']; + assert.equal(s.repair, fallback.repair); + assert.equal(s.optimize, fallback.optimize); + assert.equal(s.innovate, fallback.innovate); + }); + + it('auto maps to balanced or heuristic', function () { + process.env.EVOLVE_STRATEGY = 'auto'; + const s = resolveStrategy({}); + assert.ok(['balanced', 'early-stabilize'].includes(s.name)); + }); + + it('returned strategy has name property', function () { + process.env.EVOLVE_STRATEGY = 'harden'; + const s = resolveStrategy({}); + assert.equal(s.name, 'harden'); + }); +}); diff --git a/skills/capability-evolver/test/validationReport.test.js b/skills/capability-evolver/test/validationReport.test.js new file mode 100644 index 0000000..4ad15b1 --- /dev/null +++ b/skills/capability-evolver/test/validationReport.test.js @@ -0,0 +1,148 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildValidationReport, isValidValidationReport } = require('../src/gep/validationReport'); + +describe('buildValidationReport', function () { + it('builds a valid report with minimal input', function () { + const report = buildValidationReport({ + geneId: 'gene_test', + commands: ['echo hello'], + results: [{ ok: true, stdout: 'hello', stderr: '' }], + }); + assert.equal(report.type, 'ValidationReport'); + assert.equal(report.gene_id, 'gene_test'); + assert.equal(report.overall_ok, true); + assert.equal(report.commands.length, 1); + assert.equal(report.commands[0].command, 'echo hello'); + assert.equal(report.commands[0].ok, true); + assert.ok(report.id.startsWith('vr_')); + assert.ok(report.created_at); + assert.ok(report.asset_id); + assert.ok(report.env_fingerprint); + assert.ok(report.env_fingerprint_key); + }); + + it('marks overall_ok false when any result fails', function () { + const report = buildValidationReport({ + geneId: 'gene_fail', + commands: ['cmd1', 'cmd2'], + results: [ + { ok: true, stdout: 'ok' }, + { ok: false, stderr: 'error' }, + ], + }); + assert.equal(report.overall_ok, false); + }); + + it('marks overall_ok false when results is empty', function () { + const report = buildValidationReport({ + geneId: 'gene_empty', + commands: [], + results: [], + }); + assert.equal(report.overall_ok, false); + }); + + it('handles null geneId', function () { + const report = buildValidationReport({ + commands: ['test'], + results: [{ ok: true }], + }); + assert.equal(report.gene_id, null); + }); + + it('computes duration_ms from timestamps', function () { + const report = buildValidationReport({ + geneId: 'gene_dur', + commands: ['test'], + results: [{ ok: true }], + startedAt: 1000, + finishedAt: 2500, + }); + assert.equal(report.duration_ms, 1500); + }); + + it('duration_ms is null when timestamps missing', function () { + const report = buildValidationReport({ + geneId: 'gene_nodur', + commands: ['test'], + results: [{ ok: true }], + }); + assert.equal(report.duration_ms, null); + }); + + it('truncates stdout/stderr to 4000 chars', function () { + const longOutput = 'x'.repeat(5000); + const report = buildValidationReport({ + geneId: 'gene_long', + commands: ['test'], + results: [{ ok: true, stdout: longOutput, stderr: longOutput }], + }); + assert.equal(report.commands[0].stdout.length, 4000); + assert.equal(report.commands[0].stderr.length, 4000); + }); + + it('supports both out/stdout and err/stderr field names', function () { + const report = buildValidationReport({ + geneId: 'gene_compat', + commands: ['test'], + results: [{ ok: true, out: 'output_via_out', err: 'error_via_err' }], + }); + assert.equal(report.commands[0].stdout, 'output_via_out'); + assert.equal(report.commands[0].stderr, 'error_via_err'); + }); + + it('infers commands from results when commands not provided', function () { + const report = buildValidationReport({ + geneId: 'gene_infer', + results: [{ ok: true, cmd: 'inferred_cmd' }], + }); + assert.equal(report.commands[0].command, 'inferred_cmd'); + }); + + it('uses provided envFp instead of capturing', function () { + const customFp = { device_id: 'custom', platform: 'test' }; + const report = buildValidationReport({ + geneId: 'gene_fp', + commands: ['test'], + results: [{ ok: true }], + envFp: customFp, + }); + assert.equal(report.env_fingerprint.device_id, 'custom'); + }); +}); + +describe('isValidValidationReport', function () { + it('returns true for a valid report', function () { + const report = buildValidationReport({ + geneId: 'gene_valid', + commands: ['test'], + results: [{ ok: true }], + }); + assert.equal(isValidValidationReport(report), true); + }); + + it('returns false for null', function () { + assert.equal(isValidValidationReport(null), false); + }); + + it('returns false for non-object', function () { + assert.equal(isValidValidationReport('string'), false); + }); + + it('returns false for wrong type field', function () { + assert.equal(isValidValidationReport({ type: 'Other', id: 'x', commands: [], overall_ok: true }), false); + }); + + it('returns false for missing id', function () { + assert.equal(isValidValidationReport({ type: 'ValidationReport', commands: [], overall_ok: true }), false); + }); + + it('returns false for missing commands', function () { + assert.equal(isValidValidationReport({ type: 'ValidationReport', id: 'x', overall_ok: true }), false); + }); + + it('returns false for missing overall_ok', function () { + assert.equal(isValidValidationReport({ type: 'ValidationReport', id: 'x', commands: [] }), false); + }); +}); diff --git a/skills/self-improving-agent/.learnings/ERRORS.md b/skills/self-improving-agent/.learnings/ERRORS.md new file mode 100644 index 0000000..6bce392 --- /dev/null +++ b/skills/self-improving-agent/.learnings/ERRORS.md @@ -0,0 +1,5 @@ +# Errors Log + +Command failures, exceptions, and unexpected behaviors. + +--- diff --git a/skills/self-improving-agent/.learnings/FEATURE_REQUESTS.md b/skills/self-improving-agent/.learnings/FEATURE_REQUESTS.md new file mode 100644 index 0000000..3527277 --- /dev/null +++ b/skills/self-improving-agent/.learnings/FEATURE_REQUESTS.md @@ -0,0 +1,5 @@ +# Feature Requests + +Capabilities requested by user that don't currently exist. + +--- diff --git a/skills/self-improving-agent/.learnings/LEARNINGS.md b/skills/self-improving-agent/.learnings/LEARNINGS.md new file mode 100644 index 0000000..d31195d --- /dev/null +++ b/skills/self-improving-agent/.learnings/LEARNINGS.md @@ -0,0 +1,5 @@ +# Learnings Log + +Captured learnings, corrections, and discoveries. Review before major tasks. + +--- diff --git a/skills/self-improving-agent/SKILL.md b/skills/self-improving-agent/SKILL.md new file mode 100644 index 0000000..97b5717 --- /dev/null +++ b/skills/self-improving-agent/SKILL.md @@ -0,0 +1,647 @@ +--- +name: self-improvement +description: "Captures learnings, errors, and corrections to enable continuous improvement. Use when: (1) A command or operation fails unexpectedly, (2) User corrects Claude ('No, that's wrong...', 'Actually...'), (3) User requests a capability that doesn't exist, (4) An external API or tool fails, (5) Claude realizes its knowledge is outdated or incorrect, (6) A better approach is discovered for a recurring task. Also review learnings before major tasks." +metadata: +--- + +# Self-Improvement Skill + +Log learnings and errors to markdown files for continuous improvement. Coding agents can later process these into fixes, and important learnings get promoted to project memory. + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| Command/operation fails | Log to `.learnings/ERRORS.md` | +| User corrects you | Log to `.learnings/LEARNINGS.md` with category `correction` | +| User wants missing feature | Log to `.learnings/FEATURE_REQUESTS.md` | +| API/external tool fails | Log to `.learnings/ERRORS.md` with integration details | +| Knowledge was outdated | Log to `.learnings/LEARNINGS.md` with category `knowledge_gap` | +| Found better approach | Log to `.learnings/LEARNINGS.md` with category `best_practice` | +| Simplify/Harden recurring patterns | Log/update `.learnings/LEARNINGS.md` with `Source: simplify-and-harden` and a stable `Pattern-Key` | +| Similar to existing entry | Link with `**See Also**`, consider priority bump | +| Broadly applicable learning | Promote to `CLAUDE.md`, `AGENTS.md`, and/or `.github/copilot-instructions.md` | +| Workflow improvements | Promote to `AGENTS.md` (OpenClaw workspace) | +| Tool gotchas | Promote to `TOOLS.md` (OpenClaw workspace) | +| Behavioral patterns | Promote to `SOUL.md` (OpenClaw workspace) | + +## OpenClaw Setup (Recommended) + +OpenClaw is the primary platform for this skill. It uses workspace-based prompt injection with automatic skill loading. + +### Installation + +**Via ClawdHub (recommended):** +```bash +clawdhub install self-improving-agent +``` + +**Manual:** +```bash +git clone https://github.com/peterskoett/self-improving-agent.git ~/.openclaw/skills/self-improving-agent +``` + +Remade for openclaw from original repo : https://github.com/pskoett/pskoett-ai-skills - https://github.com/pskoett/pskoett-ai-skills/tree/main/skills/self-improvement + +### Workspace Structure + +OpenClaw injects these files into every session: + +``` +~/.openclaw/workspace/ +├── AGENTS.md # Multi-agent workflows, delegation patterns +├── SOUL.md # Behavioral guidelines, personality, principles +├── TOOLS.md # Tool capabilities, integration gotchas +├── MEMORY.md # Long-term memory (main session only) +├── memory/ # Daily memory files +│ └── YYYY-MM-DD.md +└── .learnings/ # This skill's log files + ├── LEARNINGS.md + ├── ERRORS.md + └── FEATURE_REQUESTS.md +``` + +### Create Learning Files + +```bash +mkdir -p ~/.openclaw/workspace/.learnings +``` + +Then create the log files (or copy from `assets/`): +- `LEARNINGS.md` — corrections, knowledge gaps, best practices +- `ERRORS.md` — command failures, exceptions +- `FEATURE_REQUESTS.md` — user-requested capabilities + +### Promotion Targets + +When learnings prove broadly applicable, promote them to workspace files: + +| Learning Type | Promote To | Example | +|---------------|------------|---------| +| Behavioral patterns | `SOUL.md` | "Be concise, avoid disclaimers" | +| Workflow improvements | `AGENTS.md` | "Spawn sub-agents for long tasks" | +| Tool gotchas | `TOOLS.md` | "Git push needs auth configured first" | + +### Inter-Session Communication + +OpenClaw provides tools to share learnings across sessions: + +- **sessions_list** — View active/recent sessions +- **sessions_history** — Read another session's transcript +- **sessions_send** — Send a learning to another session +- **sessions_spawn** — Spawn a sub-agent for background work + +### Optional: Enable Hook + +For automatic reminders at session start: + +```bash +# Copy hook to OpenClaw hooks directory +cp -r hooks/openclaw ~/.openclaw/hooks/self-improvement + +# Enable it +openclaw hooks enable self-improvement +``` + +See `references/openclaw-integration.md` for complete details. + +--- + +## Generic Setup (Other Agents) + +For Claude Code, Codex, Copilot, or other agents, create `.learnings/` in your project: + +```bash +mkdir -p .learnings +``` + +Copy templates from `assets/` or create files with headers. + +### Add reference to agent files AGENTS.md, CLAUDE.md, or .github/copilot-instructions.md to remind yourself to log learnings. (this is an alternative to hook-based reminders) + +#### Self-Improvement Workflow + +When errors or corrections occur: +1. Log to `.learnings/ERRORS.md`, `LEARNINGS.md`, or `FEATURE_REQUESTS.md` +2. Review and promote broadly applicable learnings to: + - `CLAUDE.md` - project facts and conventions + - `AGENTS.md` - workflows and automation + - `.github/copilot-instructions.md` - Copilot context + +## Logging Format + +### Learning Entry + +Append to `.learnings/LEARNINGS.md`: + +```markdown +## [LRN-YYYYMMDD-XXX] category + +**Logged**: ISO-8601 timestamp +**Priority**: low | medium | high | critical +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config + +### Summary +One-line description of what was learned + +### Details +Full context: what happened, what was wrong, what's correct + +### Suggested Action +Specific fix or improvement to make + +### Metadata +- Source: conversation | error | user_feedback +- Related Files: path/to/file.ext +- Tags: tag1, tag2 +- See Also: LRN-20250110-001 (if related to existing entry) +- Pattern-Key: simplify.dead_code | harden.input_validation (optional, for recurring-pattern tracking) +- Recurrence-Count: 1 (optional) +- First-Seen: 2025-01-15 (optional) +- Last-Seen: 2025-01-15 (optional) + +--- +``` + +### Error Entry + +Append to `.learnings/ERRORS.md`: + +```markdown +## [ERR-YYYYMMDD-XXX] skill_or_command_name + +**Logged**: ISO-8601 timestamp +**Priority**: high +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config + +### Summary +Brief description of what failed + +### Error +``` +Actual error message or output +``` + +### Context +- Command/operation attempted +- Input or parameters used +- Environment details if relevant + +### Suggested Fix +If identifiable, what might resolve this + +### Metadata +- Reproducible: yes | no | unknown +- Related Files: path/to/file.ext +- See Also: ERR-20250110-001 (if recurring) + +--- +``` + +### Feature Request Entry + +Append to `.learnings/FEATURE_REQUESTS.md`: + +```markdown +## [FEAT-YYYYMMDD-XXX] capability_name + +**Logged**: ISO-8601 timestamp +**Priority**: medium +**Status**: pending +**Area**: frontend | backend | infra | tests | docs | config + +### Requested Capability +What the user wanted to do + +### User Context +Why they needed it, what problem they're solving + +### Complexity Estimate +simple | medium | complex + +### Suggested Implementation +How this could be built, what it might extend + +### Metadata +- Frequency: first_time | recurring +- Related Features: existing_feature_name + +--- +``` + +## ID Generation + +Format: `TYPE-YYYYMMDD-XXX` +- TYPE: `LRN` (learning), `ERR` (error), `FEAT` (feature) +- YYYYMMDD: Current date +- XXX: Sequential number or random 3 chars (e.g., `001`, `A7B`) + +Examples: `LRN-20250115-001`, `ERR-20250115-A3F`, `FEAT-20250115-002` + +## Resolving Entries + +When an issue is fixed, update the entry: + +1. Change `**Status**: pending` → `**Status**: resolved` +2. Add resolution block after Metadata: + +```markdown +### Resolution +- **Resolved**: 2025-01-16T09:00:00Z +- **Commit/PR**: abc123 or #42 +- **Notes**: Brief description of what was done +``` + +Other status values: +- `in_progress` - Actively being worked on +- `wont_fix` - Decided not to address (add reason in Resolution notes) +- `promoted` - Elevated to CLAUDE.md, AGENTS.md, or .github/copilot-instructions.md + +## Promoting to Project Memory + +When a learning is broadly applicable (not a one-off fix), promote it to permanent project memory. + +### When to Promote + +- Learning applies across multiple files/features +- Knowledge any contributor (human or AI) should know +- Prevents recurring mistakes +- Documents project-specific conventions + +### Promotion Targets + +| Target | What Belongs There | +|--------|-------------------| +| `CLAUDE.md` | Project facts, conventions, gotchas for all Claude interactions | +| `AGENTS.md` | Agent-specific workflows, tool usage patterns, automation rules | +| `.github/copilot-instructions.md` | Project context and conventions for GitHub Copilot | +| `SOUL.md` | Behavioral guidelines, communication style, principles (OpenClaw workspace) | +| `TOOLS.md` | Tool capabilities, usage patterns, integration gotchas (OpenClaw workspace) | + +### How to Promote + +1. **Distill** the learning into a concise rule or fact +2. **Add** to appropriate section in target file (create file if needed) +3. **Update** original entry: + - Change `**Status**: pending` → `**Status**: promoted` + - Add `**Promoted**: CLAUDE.md`, `AGENTS.md`, or `.github/copilot-instructions.md` + +### Promotion Examples + +**Learning** (verbose): +> Project uses pnpm workspaces. Attempted `npm install` but failed. +> Lock file is `pnpm-lock.yaml`. Must use `pnpm install`. + +**In CLAUDE.md** (concise): +```markdown +## Build & Dependencies +- Package manager: pnpm (not npm) - use `pnpm install` +``` + +**Learning** (verbose): +> When modifying API endpoints, must regenerate TypeScript client. +> Forgetting this causes type mismatches at runtime. + +**In AGENTS.md** (actionable): +```markdown +## After API Changes +1. Regenerate client: `pnpm run generate:api` +2. Check for type errors: `pnpm tsc --noEmit` +``` + +## Recurring Pattern Detection + +If logging something similar to an existing entry: + +1. **Search first**: `grep -r "keyword" .learnings/` +2. **Link entries**: Add `**See Also**: ERR-20250110-001` in Metadata +3. **Bump priority** if issue keeps recurring +4. **Consider systemic fix**: Recurring issues often indicate: + - Missing documentation (→ promote to CLAUDE.md or .github/copilot-instructions.md) + - Missing automation (→ add to AGENTS.md) + - Architectural problem (→ create tech debt ticket) + +## Simplify & Harden Feed + +Use this workflow to ingest recurring patterns from the `simplify-and-harden` +skill and turn them into durable prompt guidance. + +### Ingestion Workflow + +1. Read `simplify_and_harden.learning_loop.candidates` from the task summary. +2. For each candidate, use `pattern_key` as the stable dedupe key. +3. Search `.learnings/LEARNINGS.md` for an existing entry with that key: + - `grep -n "Pattern-Key: " .learnings/LEARNINGS.md` +4. If found: + - Increment `Recurrence-Count` + - Update `Last-Seen` + - Add `See Also` links to related entries/tasks +5. If not found: + - Create a new `LRN-...` entry + - Set `Source: simplify-and-harden` + - Set `Pattern-Key`, `Recurrence-Count: 1`, and `First-Seen`/`Last-Seen` + +### Promotion Rule (System Prompt Feedback) + +Promote recurring patterns into agent context/system prompt files when all are true: + +- `Recurrence-Count >= 3` +- Seen across at least 2 distinct tasks +- Occurred within a 30-day window + +Promotion targets: +- `CLAUDE.md` +- `AGENTS.md` +- `.github/copilot-instructions.md` +- `SOUL.md` / `TOOLS.md` for OpenClaw workspace-level guidance when applicable + +Write promoted rules as short prevention rules (what to do before/while coding), +not long incident write-ups. + +## Periodic Review + +Review `.learnings/` at natural breakpoints: + +### When to Review +- Before starting a new major task +- After completing a feature +- When working in an area with past learnings +- Weekly during active development + +### Quick Status Check +```bash +# Count pending items +grep -h "Status\*\*: pending" .learnings/*.md | wc -l + +# List pending high-priority items +grep -B5 "Priority\*\*: high" .learnings/*.md | grep "^## \[" + +# Find learnings for a specific area +grep -l "Area\*\*: backend" .learnings/*.md +``` + +### Review Actions +- Resolve fixed items +- Promote applicable learnings +- Link related entries +- Escalate recurring issues + +## Detection Triggers + +Automatically log when you notice: + +**Corrections** (→ learning with `correction` category): +- "No, that's not right..." +- "Actually, it should be..." +- "You're wrong about..." +- "That's outdated..." + +**Feature Requests** (→ feature request): +- "Can you also..." +- "I wish you could..." +- "Is there a way to..." +- "Why can't you..." + +**Knowledge Gaps** (→ learning with `knowledge_gap` category): +- User provides information you didn't know +- Documentation you referenced is outdated +- API behavior differs from your understanding + +**Errors** (→ error entry): +- Command returns non-zero exit code +- Exception or stack trace +- Unexpected output or behavior +- Timeout or connection failure + +## Priority Guidelines + +| Priority | When to Use | +|----------|-------------| +| `critical` | Blocks core functionality, data loss risk, security issue | +| `high` | Significant impact, affects common workflows, recurring issue | +| `medium` | Moderate impact, workaround exists | +| `low` | Minor inconvenience, edge case, nice-to-have | + +## Area Tags + +Use to filter learnings by codebase region: + +| Area | Scope | +|------|-------| +| `frontend` | UI, components, client-side code | +| `backend` | API, services, server-side code | +| `infra` | CI/CD, deployment, Docker, cloud | +| `tests` | Test files, testing utilities, coverage | +| `docs` | Documentation, comments, READMEs | +| `config` | Configuration files, environment, settings | + +## Best Practices + +1. **Log immediately** - context is freshest right after the issue +2. **Be specific** - future agents need to understand quickly +3. **Include reproduction steps** - especially for errors +4. **Link related files** - makes fixes easier +5. **Suggest concrete fixes** - not just "investigate" +6. **Use consistent categories** - enables filtering +7. **Promote aggressively** - if in doubt, add to CLAUDE.md or .github/copilot-instructions.md +8. **Review regularly** - stale learnings lose value + +## Gitignore Options + +**Keep learnings local** (per-developer): +```gitignore +.learnings/ +``` + +**Track learnings in repo** (team-wide): +Don't add to .gitignore - learnings become shared knowledge. + +**Hybrid** (track templates, ignore entries): +```gitignore +.learnings/*.md +!.learnings/.gitkeep +``` + +## Hook Integration + +Enable automatic reminders through agent hooks. This is **opt-in** - you must explicitly configure hooks. + +### Quick Setup (Claude Code / Codex) + +Create `.claude/settings.json` in your project: + +```json +{ + "hooks": { + "UserPromptSubmit": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "./skills/self-improvement/scripts/activator.sh" + }] + }] + } +} +``` + +This injects a learning evaluation reminder after each prompt (~50-100 tokens overhead). + +### Full Setup (With Error Detection) + +```json +{ + "hooks": { + "UserPromptSubmit": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "./skills/self-improvement/scripts/activator.sh" + }] + }], + "PostToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "./skills/self-improvement/scripts/error-detector.sh" + }] + }] + } +} +``` + +### Available Hook Scripts + +| Script | Hook Type | Purpose | +|--------|-----------|---------| +| `scripts/activator.sh` | UserPromptSubmit | Reminds to evaluate learnings after tasks | +| `scripts/error-detector.sh` | PostToolUse (Bash) | Triggers on command errors | + +See `references/hooks-setup.md` for detailed configuration and troubleshooting. + +## Automatic Skill Extraction + +When a learning is valuable enough to become a reusable skill, extract it using the provided helper. + +### Skill Extraction Criteria + +A learning qualifies for skill extraction when ANY of these apply: + +| Criterion | Description | +|-----------|-------------| +| **Recurring** | Has `See Also` links to 2+ similar issues | +| **Verified** | Status is `resolved` with working fix | +| **Non-obvious** | Required actual debugging/investigation to discover | +| **Broadly applicable** | Not project-specific; useful across codebases | +| **User-flagged** | User says "save this as a skill" or similar | + +### Extraction Workflow + +1. **Identify candidate**: Learning meets extraction criteria +2. **Run helper** (or create manually): + ```bash + ./skills/self-improvement/scripts/extract-skill.sh skill-name --dry-run + ./skills/self-improvement/scripts/extract-skill.sh skill-name + ``` +3. **Customize SKILL.md**: Fill in template with learning content +4. **Update learning**: Set status to `promoted_to_skill`, add `Skill-Path` +5. **Verify**: Read skill in fresh session to ensure it's self-contained + +### Manual Extraction + +If you prefer manual creation: + +1. Create `skills//SKILL.md` +2. Use template from `assets/SKILL-TEMPLATE.md` +3. Follow [Agent Skills spec](https://agentskills.io/specification): + - YAML frontmatter with `name` and `description` + - Name must match folder name + - No README.md inside skill folder + +### Extraction Detection Triggers + +Watch for these signals that a learning should become a skill: + +**In conversation:** +- "Save this as a skill" +- "I keep running into this" +- "This would be useful for other projects" +- "Remember this pattern" + +**In learning entries:** +- Multiple `See Also` links (recurring issue) +- High priority + resolved status +- Category: `best_practice` with broad applicability +- User feedback praising the solution + +### Skill Quality Gates + +Before extraction, verify: + +- [ ] Solution is tested and working +- [ ] Description is clear without original context +- [ ] Code examples are self-contained +- [ ] No project-specific hardcoded values +- [ ] Follows skill naming conventions (lowercase, hyphens) + +## Multi-Agent Support + +This skill works across different AI coding agents with agent-specific activation. + +### Claude Code + +**Activation**: Hooks (UserPromptSubmit, PostToolUse) +**Setup**: `.claude/settings.json` with hook configuration +**Detection**: Automatic via hook scripts + +### Codex CLI + +**Activation**: Hooks (same pattern as Claude Code) +**Setup**: `.codex/settings.json` with hook configuration +**Detection**: Automatic via hook scripts + +### GitHub Copilot + +**Activation**: Manual (no hook support) +**Setup**: Add to `.github/copilot-instructions.md`: + +```markdown +## Self-Improvement + +After solving non-obvious issues, consider logging to `.learnings/`: +1. Use format from self-improvement skill +2. Link related entries with See Also +3. Promote high-value learnings to skills + +Ask in chat: "Should I log this as a learning?" +``` + +**Detection**: Manual review at session end + +### OpenClaw + +**Activation**: Workspace injection + inter-agent messaging +**Setup**: See "OpenClaw Setup" section above +**Detection**: Via session tools and workspace files + +### Agent-Agnostic Guidance + +Regardless of agent, apply self-improvement when you: + +1. **Discover something non-obvious** - solution wasn't immediate +2. **Correct yourself** - initial approach was wrong +3. **Learn project conventions** - discovered undocumented patterns +4. **Hit unexpected errors** - especially if diagnosis was difficult +5. **Find better approaches** - improved on your original solution + +### Copilot Chat Integration + +For Copilot users, add this to your prompts when relevant: + +> After completing this task, evaluate if any learnings should be logged to `.learnings/` using the self-improvement skill format. + +Or use quick prompts: +- "Log this to learnings" +- "Create a skill from this solution" +- "Check .learnings/ for related issues" diff --git a/skills/self-improving-agent/_meta.json b/skills/self-improving-agent/_meta.json new file mode 100644 index 0000000..47df044 --- /dev/null +++ b/skills/self-improving-agent/_meta.json @@ -0,0 +1,42 @@ +{ + "owner": "pskoett", + "slug": "self-improving-agent", + "displayName": "self-improving-agent", + "latest": { + "version": "1.0.11", + "publishedAt": 1771777713337, + "commit": "https://github.com/openclaw/skills/commit/dfb14dbc1022dd030c0c41790909049dbb6fa37a" + }, + "history": [ + { + "version": "1.0.7", + "publishedAt": 1771697548113, + "commit": "https://github.com/openclaw/skills/commit/15ec46cddb560b03da236e00bedf1b068e056ce1" + }, + { + "version": "1.0.5", + "publishedAt": 1770103244219, + "commit": "https://github.com/clawdbot/skills/commit/601a019aee6dc1b6d889c1e65fe44b3107364e42" + }, + { + "version": "1.0.4", + "publishedAt": 1769863140016, + "commit": "https://github.com/clawdbot/skills/commit/33f828aa8942ae6d2f53704754ae30d19bd6303b" + }, + { + "version": "1.0.2", + "publishedAt": 1769420552012, + "commit": "https://github.com/clawdbot/skills/commit/98cefe04c6d8d038212638882bfaddec8e0cfb85" + }, + { + "version": "1.0.1", + "publishedAt": 1768859807396, + "commit": "https://github.com/clawdbot/skills/commit/6d0b9a337eaff501ba62f03fb61a1f4749d109bc" + }, + { + "version": "1.0.0", + "publishedAt": 1767632598365, + "commit": "https://github.com/clawdbot/skills/commit/c172fdfbe40eba64409a51dd634631a6814ffe90" + } + ] +} diff --git a/skills/self-improving-agent/assets/LEARNINGS.md b/skills/self-improving-agent/assets/LEARNINGS.md new file mode 100644 index 0000000..6993f9b --- /dev/null +++ b/skills/self-improving-agent/assets/LEARNINGS.md @@ -0,0 +1,45 @@ +# Learnings + +Corrections, insights, and knowledge gaps captured during development. + +**Categories**: correction | insight | knowledge_gap | best_practice +**Areas**: frontend | backend | infra | tests | docs | config +**Statuses**: pending | in_progress | resolved | wont_fix | promoted | promoted_to_skill + +## Status Definitions + +| Status | Meaning | +|--------|---------| +| `pending` | Not yet addressed | +| `in_progress` | Actively being worked on | +| `resolved` | Issue fixed or knowledge integrated | +| `wont_fix` | Decided not to address (reason in Resolution) | +| `promoted` | Elevated to CLAUDE.md, AGENTS.md, or copilot-instructions.md | +| `promoted_to_skill` | Extracted as a reusable skill | + +## Skill Extraction Fields + +When a learning is promoted to a skill, add these fields: + +```markdown +**Status**: promoted_to_skill +**Skill-Path**: skills/skill-name +``` + +Example: +```markdown +## [LRN-20250115-001] best_practice + +**Logged**: 2025-01-15T10:00:00Z +**Priority**: high +**Status**: promoted_to_skill +**Skill-Path**: skills/docker-m1-fixes +**Area**: infra + +### Summary +Docker build fails on Apple Silicon due to platform mismatch +... +``` + +--- + diff --git a/skills/self-improving-agent/assets/SKILL-TEMPLATE.md b/skills/self-improving-agent/assets/SKILL-TEMPLATE.md new file mode 100644 index 0000000..0162134 --- /dev/null +++ b/skills/self-improving-agent/assets/SKILL-TEMPLATE.md @@ -0,0 +1,177 @@ +# Skill Template + +Template for creating skills extracted from learnings. Copy and customize. + +--- + +## SKILL.md Template + +```markdown +--- +name: skill-name-here +description: "Concise description of when and why to use this skill. Include trigger conditions." +--- + +# Skill Name + +Brief introduction explaining the problem this skill solves and its origin. + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| [Trigger 1] | [Action 1] | +| [Trigger 2] | [Action 2] | + +## Background + +Why this knowledge matters. What problems it prevents. Context from the original learning. + +## Solution + +### Step-by-Step + +1. First step with code or command +2. Second step +3. Verification step + +### Code Example + +\`\`\`language +// Example code demonstrating the solution +\`\`\` + +## Common Variations + +- **Variation A**: Description and how to handle +- **Variation B**: Description and how to handle + +## Gotchas + +- Warning or common mistake #1 +- Warning or common mistake #2 + +## Related + +- Link to related documentation +- Link to related skill + +## Source + +Extracted from learning entry. +- **Learning ID**: LRN-YYYYMMDD-XXX +- **Original Category**: correction | insight | knowledge_gap | best_practice +- **Extraction Date**: YYYY-MM-DD +``` + +--- + +## Minimal Template + +For simple skills that don't need all sections: + +```markdown +--- +name: skill-name-here +description: "What this skill does and when to use it." +--- + +# Skill Name + +[Problem statement in one sentence] + +## Solution + +[Direct solution with code/commands] + +## Source + +- Learning ID: LRN-YYYYMMDD-XXX +``` + +--- + +## Template with Scripts + +For skills that include executable helpers: + +```markdown +--- +name: skill-name-here +description: "What this skill does and when to use it." +--- + +# Skill Name + +[Introduction] + +## Quick Reference + +| Command | Purpose | +|---------|---------| +| `./scripts/helper.sh` | [What it does] | +| `./scripts/validate.sh` | [What it does] | + +## Usage + +### Automated (Recommended) + +\`\`\`bash +./skills/skill-name/scripts/helper.sh [args] +\`\`\` + +### Manual Steps + +1. Step one +2. Step two + +## Scripts + +| Script | Description | +|--------|-------------| +| `scripts/helper.sh` | Main utility | +| `scripts/validate.sh` | Validation checker | + +## Source + +- Learning ID: LRN-YYYYMMDD-XXX +``` + +--- + +## Naming Conventions + +- **Skill name**: lowercase, hyphens for spaces + - Good: `docker-m1-fixes`, `api-timeout-patterns` + - Bad: `Docker_M1_Fixes`, `APITimeoutPatterns` + +- **Description**: Start with action verb, mention trigger + - Good: "Handles Docker build failures on Apple Silicon. Use when builds fail with platform mismatch." + - Bad: "Docker stuff" + +- **Files**: + - `SKILL.md` - Required, main documentation + - `scripts/` - Optional, executable code + - `references/` - Optional, detailed docs + - `assets/` - Optional, templates + +--- + +## Extraction Checklist + +Before creating a skill from a learning: + +- [ ] Learning is verified (status: resolved) +- [ ] Solution is broadly applicable (not one-off) +- [ ] Content is complete (has all needed context) +- [ ] Name follows conventions +- [ ] Description is concise but informative +- [ ] Quick Reference table is actionable +- [ ] Code examples are tested +- [ ] Source learning ID is recorded + +After creating: + +- [ ] Update original learning with `promoted_to_skill` status +- [ ] Add `Skill-Path: skills/skill-name` to learning metadata +- [ ] Test skill by reading it in a fresh session diff --git a/skills/self-improving-agent/hooks/openclaw/HOOK.md b/skills/self-improving-agent/hooks/openclaw/HOOK.md new file mode 100644 index 0000000..df67405 --- /dev/null +++ b/skills/self-improving-agent/hooks/openclaw/HOOK.md @@ -0,0 +1,23 @@ +--- +name: self-improvement +description: "Injects self-improvement reminder during agent bootstrap" +metadata: {"openclaw":{"emoji":"🧠","events":["agent:bootstrap"]}} +--- + +# Self-Improvement Hook + +Injects a reminder to evaluate learnings during agent bootstrap. + +## What It Does + +- Fires on `agent:bootstrap` (before workspace files are injected) +- Adds a reminder block to check `.learnings/` for relevant entries +- Prompts the agent to log corrections, errors, and discoveries + +## Configuration + +No configuration needed. Enable with: + +```bash +openclaw hooks enable self-improvement +``` diff --git a/skills/self-improving-agent/hooks/openclaw/handler.js b/skills/self-improving-agent/hooks/openclaw/handler.js new file mode 100644 index 0000000..73278ea --- /dev/null +++ b/skills/self-improving-agent/hooks/openclaw/handler.js @@ -0,0 +1,56 @@ +/** + * Self-Improvement Hook for OpenClaw + * + * Injects a reminder to evaluate learnings during agent bootstrap. + * Fires on agent:bootstrap event before workspace files are injected. + */ + +const REMINDER_CONTENT = ` +## Self-Improvement Reminder + +After completing tasks, evaluate if any learnings should be captured: + +**Log when:** +- User corrects you → \`.learnings/LEARNINGS.md\` +- Command/operation fails → \`.learnings/ERRORS.md\` +- User wants missing capability → \`.learnings/FEATURE_REQUESTS.md\` +- You discover your knowledge was wrong → \`.learnings/LEARNINGS.md\` +- You find a better approach → \`.learnings/LEARNINGS.md\` + +**Promote when pattern is proven:** +- Behavioral patterns → \`SOUL.md\` +- Workflow improvements → \`AGENTS.md\` +- Tool gotchas → \`TOOLS.md\` + +Keep entries simple: date, title, what happened, what to do differently. +`.trim(); + +const handler = async (event) => { + // Safety checks for event structure + if (!event || typeof event !== 'object') { + return; + } + + // Only handle agent:bootstrap events + if (event.type !== 'agent' || event.action !== 'bootstrap') { + return; + } + + // Safety check for context + if (!event.context || typeof event.context !== 'object') { + return; + } + + // Inject the reminder as a virtual bootstrap file + // Check that bootstrapFiles is an array before pushing + if (Array.isArray(event.context.bootstrapFiles)) { + event.context.bootstrapFiles.push({ + path: 'SELF_IMPROVEMENT_REMINDER.md', + content: REMINDER_CONTENT, + virtual: true, + }); + } +}; + +module.exports = handler; +module.exports.default = handler; diff --git a/skills/self-improving-agent/hooks/openclaw/handler.ts b/skills/self-improving-agent/hooks/openclaw/handler.ts new file mode 100644 index 0000000..9ec23f3 --- /dev/null +++ b/skills/self-improving-agent/hooks/openclaw/handler.ts @@ -0,0 +1,62 @@ +/** + * Self-Improvement Hook for OpenClaw + * + * Injects a reminder to evaluate learnings during agent bootstrap. + * Fires on agent:bootstrap event before workspace files are injected. + */ + +import type { HookHandler } from 'openclaw/hooks'; + +const REMINDER_CONTENT = `## Self-Improvement Reminder + +After completing tasks, evaluate if any learnings should be captured: + +**Log when:** +- User corrects you → \`.learnings/LEARNINGS.md\` +- Command/operation fails → \`.learnings/ERRORS.md\` +- User wants missing capability → \`.learnings/FEATURE_REQUESTS.md\` +- You discover your knowledge was wrong → \`.learnings/LEARNINGS.md\` +- You find a better approach → \`.learnings/LEARNINGS.md\` + +**Promote when pattern is proven:** +- Behavioral patterns → \`SOUL.md\` +- Workflow improvements → \`AGENTS.md\` +- Tool gotchas → \`TOOLS.md\` + +Keep entries simple: date, title, what happened, what to do differently.`; + +const handler: HookHandler = async (event) => { + // Safety checks for event structure + if (!event || typeof event !== 'object') { + return; + } + + // Only handle agent:bootstrap events + if (event.type !== 'agent' || event.action !== 'bootstrap') { + return; + } + + // Safety check for context + if (!event.context || typeof event.context !== 'object') { + return; + } + + // Skip sub-agent sessions to avoid bootstrap issues + // Sub-agents have sessionKey patterns like "agent:main:subagent:..." + const sessionKey = event.sessionKey || ''; + if (sessionKey.includes(':subagent:')) { + return; + } + + // Inject the reminder as a virtual bootstrap file + // Check that bootstrapFiles is an array before pushing + if (Array.isArray(event.context.bootstrapFiles)) { + event.context.bootstrapFiles.push({ + path: 'SELF_IMPROVEMENT_REMINDER.md', + content: REMINDER_CONTENT, + virtual: true, + }); + } +}; + +export default handler; diff --git a/skills/self-improving-agent/references/examples.md b/skills/self-improving-agent/references/examples.md new file mode 100644 index 0000000..1c1db15 --- /dev/null +++ b/skills/self-improving-agent/references/examples.md @@ -0,0 +1,374 @@ +# Entry Examples + +Concrete examples of well-formatted entries with all fields. + +## Learning: Correction + +```markdown +## [LRN-20250115-001] correction + +**Logged**: 2025-01-15T10:30:00Z +**Priority**: high +**Status**: pending +**Area**: tests + +### Summary +Incorrectly assumed pytest fixtures are scoped to function by default + +### Details +When writing test fixtures, I assumed all fixtures were function-scoped. +User corrected that while function scope is the default, the codebase +convention uses module-scoped fixtures for database connections to +improve test performance. + +### Suggested Action +When creating fixtures that involve expensive setup (DB, network), +check existing fixtures for scope patterns before defaulting to function scope. + +### Metadata +- Source: user_feedback +- Related Files: tests/conftest.py +- Tags: pytest, testing, fixtures + +--- +``` + +## Learning: Knowledge Gap (Resolved) + +```markdown +## [LRN-20250115-002] knowledge_gap + +**Logged**: 2025-01-15T14:22:00Z +**Priority**: medium +**Status**: resolved +**Area**: config + +### Summary +Project uses pnpm not npm for package management + +### Details +Attempted to run `npm install` but project uses pnpm workspaces. +Lock file is `pnpm-lock.yaml`, not `package-lock.json`. + +### Suggested Action +Check for `pnpm-lock.yaml` or `pnpm-workspace.yaml` before assuming npm. +Use `pnpm install` for this project. + +### Metadata +- Source: error +- Related Files: pnpm-lock.yaml, pnpm-workspace.yaml +- Tags: package-manager, pnpm, setup + +### Resolution +- **Resolved**: 2025-01-15T14:30:00Z +- **Commit/PR**: N/A - knowledge update +- **Notes**: Added to CLAUDE.md for future reference + +--- +``` + +## Learning: Promoted to CLAUDE.md + +```markdown +## [LRN-20250115-003] best_practice + +**Logged**: 2025-01-15T16:00:00Z +**Priority**: high +**Status**: promoted +**Promoted**: CLAUDE.md +**Area**: backend + +### Summary +API responses must include correlation ID from request headers + +### Details +All API responses should echo back the X-Correlation-ID header from +the request. This is required for distributed tracing. Responses +without this header break the observability pipeline. + +### Suggested Action +Always include correlation ID passthrough in API handlers. + +### Metadata +- Source: user_feedback +- Related Files: src/middleware/correlation.ts +- Tags: api, observability, tracing + +--- +``` + +## Learning: Promoted to AGENTS.md + +```markdown +## [LRN-20250116-001] best_practice + +**Logged**: 2025-01-16T09:00:00Z +**Priority**: high +**Status**: promoted +**Promoted**: AGENTS.md +**Area**: backend + +### Summary +Must regenerate API client after OpenAPI spec changes + +### Details +When modifying API endpoints, the TypeScript client must be regenerated. +Forgetting this causes type mismatches that only appear at runtime. +The generate script also runs validation. + +### Suggested Action +Add to agent workflow: after any API changes, run `pnpm run generate:api`. + +### Metadata +- Source: error +- Related Files: openapi.yaml, src/client/api.ts +- Tags: api, codegen, typescript + +--- +``` + +## Error Entry + +```markdown +## [ERR-20250115-A3F] docker_build + +**Logged**: 2025-01-15T09:15:00Z +**Priority**: high +**Status**: pending +**Area**: infra + +### Summary +Docker build fails on M1 Mac due to platform mismatch + +### Error +``` +error: failed to solve: python:3.11-slim: no match for platform linux/arm64 +``` + +### Context +- Command: `docker build -t myapp .` +- Dockerfile uses `FROM python:3.11-slim` +- Running on Apple Silicon (M1/M2) + +### Suggested Fix +Add platform flag: `docker build --platform linux/amd64 -t myapp .` +Or update Dockerfile: `FROM --platform=linux/amd64 python:3.11-slim` + +### Metadata +- Reproducible: yes +- Related Files: Dockerfile + +--- +``` + +## Error Entry: Recurring Issue + +```markdown +## [ERR-20250120-B2C] api_timeout + +**Logged**: 2025-01-20T11:30:00Z +**Priority**: critical +**Status**: pending +**Area**: backend + +### Summary +Third-party payment API timeout during checkout + +### Error +``` +TimeoutError: Request to payments.example.com timed out after 30000ms +``` + +### Context +- Command: POST /api/checkout +- Timeout set to 30s +- Occurs during peak hours (lunch, evening) + +### Suggested Fix +Implement retry with exponential backoff. Consider circuit breaker pattern. + +### Metadata +- Reproducible: yes (during peak hours) +- Related Files: src/services/payment.ts +- See Also: ERR-20250115-X1Y, ERR-20250118-Z3W + +--- +``` + +## Feature Request + +```markdown +## [FEAT-20250115-001] export_to_csv + +**Logged**: 2025-01-15T16:45:00Z +**Priority**: medium +**Status**: pending +**Area**: backend + +### Requested Capability +Export analysis results to CSV format + +### User Context +User runs weekly reports and needs to share results with non-technical +stakeholders in Excel. Currently copies output manually. + +### Complexity Estimate +simple + +### Suggested Implementation +Add `--output csv` flag to the analyze command. Use standard csv module. +Could extend existing `--output json` pattern. + +### Metadata +- Frequency: recurring +- Related Features: analyze command, json output + +--- +``` + +## Feature Request: Resolved + +```markdown +## [FEAT-20250110-002] dark_mode + +**Logged**: 2025-01-10T14:00:00Z +**Priority**: low +**Status**: resolved +**Area**: frontend + +### Requested Capability +Dark mode support for the dashboard + +### User Context +User works late hours and finds the bright interface straining. +Several other users have mentioned this informally. + +### Complexity Estimate +medium + +### Suggested Implementation +Use CSS variables for colors. Add toggle in user settings. +Consider system preference detection. + +### Metadata +- Frequency: recurring +- Related Features: user settings, theme system + +### Resolution +- **Resolved**: 2025-01-18T16:00:00Z +- **Commit/PR**: #142 +- **Notes**: Implemented with system preference detection and manual toggle + +--- +``` + +## Learning: Promoted to Skill + +```markdown +## [LRN-20250118-001] best_practice + +**Logged**: 2025-01-18T11:00:00Z +**Priority**: high +**Status**: promoted_to_skill +**Skill-Path**: skills/docker-m1-fixes +**Area**: infra + +### Summary +Docker build fails on Apple Silicon due to platform mismatch + +### Details +When building Docker images on M1/M2 Macs, the build fails because +the base image doesn't have an ARM64 variant. This is a common issue +that affects many developers. + +### Suggested Action +Add `--platform linux/amd64` to docker build command, or use +`FROM --platform=linux/amd64` in Dockerfile. + +### Metadata +- Source: error +- Related Files: Dockerfile +- Tags: docker, arm64, m1, apple-silicon +- See Also: ERR-20250115-A3F, ERR-20250117-B2D + +--- +``` + +## Extracted Skill Example + +When the above learning is extracted as a skill, it becomes: + +**File**: `skills/docker-m1-fixes/SKILL.md` + +```markdown +--- +name: docker-m1-fixes +description: "Fixes Docker build failures on Apple Silicon (M1/M2). Use when docker build fails with platform mismatch errors." +--- + +# Docker M1 Fixes + +Solutions for Docker build issues on Apple Silicon Macs. + +## Quick Reference + +| Error | Fix | +|-------|-----| +| `no match for platform linux/arm64` | Add `--platform linux/amd64` to build | +| Image runs but crashes | Use emulation or find ARM-compatible base | + +## The Problem + +Many Docker base images don't have ARM64 variants. When building on +Apple Silicon (M1/M2/M3), Docker attempts to pull ARM64 images by +default, causing platform mismatch errors. + +## Solutions + +### Option 1: Build Flag (Recommended) + +Add platform flag to your build command: + +\`\`\`bash +docker build --platform linux/amd64 -t myapp . +\`\`\` + +### Option 2: Dockerfile Modification + +Specify platform in the FROM instruction: + +\`\`\`dockerfile +FROM --platform=linux/amd64 python:3.11-slim +\`\`\` + +### Option 3: Docker Compose + +Add platform to your service: + +\`\`\`yaml +services: + app: + platform: linux/amd64 + build: . +\`\`\` + +## Trade-offs + +| Approach | Pros | Cons | +|----------|------|------| +| Build flag | No file changes | Must remember flag | +| Dockerfile | Explicit, versioned | Affects all builds | +| Compose | Convenient for dev | Requires compose | + +## Performance Note + +Running AMD64 images on ARM64 uses Rosetta 2 emulation. This works +for development but may be slower. For production, find ARM-native +alternatives when possible. + +## Source + +- Learning ID: LRN-20250118-001 +- Category: best_practice +- Extraction Date: 2025-01-18 +``` diff --git a/skills/self-improving-agent/references/hooks-setup.md b/skills/self-improving-agent/references/hooks-setup.md new file mode 100644 index 0000000..08b5dd1 --- /dev/null +++ b/skills/self-improving-agent/references/hooks-setup.md @@ -0,0 +1,223 @@ +# Hook Setup Guide + +Configure automatic self-improvement triggers for AI coding agents. + +## Overview + +Hooks enable proactive learning capture by injecting reminders at key moments: +- **UserPromptSubmit**: Reminder after each prompt to evaluate learnings +- **PostToolUse (Bash)**: Error detection when commands fail + +## Claude Code Setup + +### Option 1: Project-Level Configuration + +Create `.claude/settings.json` in your project root: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "./skills/self-improvement/scripts/activator.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "./skills/self-improvement/scripts/error-detector.sh" + } + ] + } + ] + } +} +``` + +### Option 2: User-Level Configuration + +Add to `~/.claude/settings.json` for global activation: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.claude/skills/self-improvement/scripts/activator.sh" + } + ] + } + ] + } +} +``` + +### Minimal Setup (Activator Only) + +For lower overhead, use only the UserPromptSubmit hook: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "./skills/self-improvement/scripts/activator.sh" + } + ] + } + ] + } +} +``` + +## Codex CLI Setup + +Codex uses the same hook system as Claude Code. Create `.codex/settings.json`: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "./skills/self-improvement/scripts/activator.sh" + } + ] + } + ] + } +} +``` + +## GitHub Copilot Setup + +Copilot doesn't support hooks directly. Instead, add guidance to `.github/copilot-instructions.md`: + +```markdown +## Self-Improvement + +After completing tasks that involved: +- Debugging non-obvious issues +- Discovering workarounds +- Learning project-specific patterns +- Resolving unexpected errors + +Consider logging the learning to `.learnings/` using the format from the self-improvement skill. + +For high-value learnings that would benefit other sessions, consider skill extraction. +``` + +## Verification + +### Test Activator Hook + +1. Enable the hook configuration +2. Start a new Claude Code session +3. Send any prompt +4. Verify you see `` in the context + +### Test Error Detector Hook + +1. Enable PostToolUse hook for Bash +2. Run a command that fails: `ls /nonexistent/path` +3. Verify you see `` reminder + +### Dry Run Extract Script + +```bash +./skills/self-improvement/scripts/extract-skill.sh test-skill --dry-run +``` + +Expected output shows the skill scaffold that would be created. + +## Troubleshooting + +### Hook Not Triggering + +1. **Check script permissions**: `chmod +x scripts/*.sh` +2. **Verify path**: Use absolute paths or paths relative to project root +3. **Check settings location**: Project vs user-level settings +4. **Restart session**: Hooks are loaded at session start + +### Permission Denied + +```bash +chmod +x ./skills/self-improvement/scripts/activator.sh +chmod +x ./skills/self-improvement/scripts/error-detector.sh +chmod +x ./skills/self-improvement/scripts/extract-skill.sh +``` + +### Script Not Found + +If using relative paths, ensure you're in the correct directory or use absolute paths: + +```json +{ + "command": "/absolute/path/to/skills/self-improvement/scripts/activator.sh" +} +``` + +### Too Much Overhead + +If the activator feels intrusive: + +1. **Use minimal setup**: Only UserPromptSubmit, skip PostToolUse +2. **Add matcher filter**: Only trigger for certain prompts: + +```json +{ + "matcher": "fix|debug|error|issue", + "hooks": [...] +} +``` + +## Hook Output Budget + +The activator is designed to be lightweight: +- **Target**: ~50-100 tokens per activation +- **Content**: Structured reminder, not verbose instructions +- **Format**: XML tags for easy parsing + +If you need to reduce overhead further, you can edit `activator.sh` to output less text. + +## Security Considerations + +- Hook scripts run with the same permissions as Claude Code +- Scripts only output text; they don't modify files or run commands +- Error detector reads `CLAUDE_TOOL_OUTPUT` environment variable +- All scripts are opt-in (you must configure them explicitly) + +## Disabling Hooks + +To temporarily disable without removing configuration: + +1. **Comment out in settings**: +```json +{ + "hooks": { + // "UserPromptSubmit": [...] + } +} +``` + +2. **Or delete the settings file**: Hooks won't run without configuration diff --git a/skills/self-improving-agent/references/openclaw-integration.md b/skills/self-improving-agent/references/openclaw-integration.md new file mode 100644 index 0000000..09f0193 --- /dev/null +++ b/skills/self-improving-agent/references/openclaw-integration.md @@ -0,0 +1,248 @@ +# OpenClaw Integration + +Complete setup and usage guide for integrating the self-improvement skill with OpenClaw. + +## Overview + +OpenClaw uses workspace-based prompt injection combined with event-driven hooks. Context is injected from workspace files at session start, and hooks can trigger on lifecycle events. + +## Workspace Structure + +``` +~/.openclaw/ +├── workspace/ # Working directory +│ ├── AGENTS.md # Multi-agent coordination patterns +│ ├── SOUL.md # Behavioral guidelines and personality +│ ├── TOOLS.md # Tool capabilities and gotchas +│ ├── MEMORY.md # Long-term memory (main session only) +│ └── memory/ # Daily memory files +│ └── YYYY-MM-DD.md +├── skills/ # Installed skills +│ └── / +│ └── SKILL.md +└── hooks/ # Custom hooks + └── / + ├── HOOK.md + └── handler.ts +``` + +## Quick Setup + +### 1. Install the Skill + +```bash +clawdhub install self-improving-agent +``` + +Or copy manually: + +```bash +cp -r self-improving-agent ~/.openclaw/skills/ +``` + +### 2. Install the Hook (Optional) + +Copy the hook to OpenClaw's hooks directory: + +```bash +cp -r hooks/openclaw ~/.openclaw/hooks/self-improvement +``` + +Enable the hook: + +```bash +openclaw hooks enable self-improvement +``` + +### 3. Create Learning Files + +Create the `.learnings/` directory in your workspace: + +```bash +mkdir -p ~/.openclaw/workspace/.learnings +``` + +Or in the skill directory: + +```bash +mkdir -p ~/.openclaw/skills/self-improving-agent/.learnings +``` + +## Injected Prompt Files + +### AGENTS.md + +Purpose: Multi-agent workflows and delegation patterns. + +```markdown +# Agent Coordination + +## Delegation Rules +- Use explore agent for open-ended codebase questions +- Spawn sub-agents for long-running tasks +- Use sessions_send for cross-session communication + +## Session Handoff +When delegating to another session: +1. Provide full context in the handoff message +2. Include relevant file paths +3. Specify expected output format +``` + +### SOUL.md + +Purpose: Behavioral guidelines and communication style. + +```markdown +# Behavioral Guidelines + +## Communication Style +- Be direct and concise +- Avoid unnecessary caveats and disclaimers +- Use technical language appropriate to context + +## Error Handling +- Admit mistakes promptly +- Provide corrected information immediately +- Log significant errors to learnings +``` + +### TOOLS.md + +Purpose: Tool capabilities, integration gotchas, local configuration. + +```markdown +# Tool Knowledge + +## Self-Improvement Skill +Log learnings to `.learnings/` for continuous improvement. + +## Local Tools +- Document tool-specific gotchas here +- Note authentication requirements +- Track integration quirks +``` + +## Learning Workflow + +### Capturing Learnings + +1. **In-session**: Log to `.learnings/` as usual +2. **Cross-session**: Promote to workspace files + +### Promotion Decision Tree + +``` +Is the learning project-specific? +├── Yes → Keep in .learnings/ +└── No → Is it behavioral/style-related? + ├── Yes → Promote to SOUL.md + └── No → Is it tool-related? + ├── Yes → Promote to TOOLS.md + └── No → Promote to AGENTS.md (workflow) +``` + +### Promotion Format Examples + +**From learning:** +> Git push to GitHub fails without auth configured - triggers desktop prompt + +**To TOOLS.md:** +```markdown +## Git +- Don't push without confirming auth is configured +- Use `gh auth status` to check GitHub CLI auth +``` + +## Inter-Agent Communication + +OpenClaw provides tools for cross-session communication: + +### sessions_list + +View active and recent sessions: +``` +sessions_list(activeMinutes=30, messageLimit=3) +``` + +### sessions_history + +Read transcript from another session: +``` +sessions_history(sessionKey="session-id", limit=50) +``` + +### sessions_send + +Send message to another session: +``` +sessions_send(sessionKey="session-id", message="Learning: API requires X-Custom-Header") +``` + +### sessions_spawn + +Spawn a background sub-agent: +``` +sessions_spawn(task="Research X and report back", label="research") +``` + +## Available Hook Events + +| Event | When It Fires | +|-------|---------------| +| `agent:bootstrap` | Before workspace files inject | +| `command:new` | When `/new` command issued | +| `command:reset` | When `/reset` command issued | +| `command:stop` | When `/stop` command issued | +| `gateway:startup` | When gateway starts | + +## Detection Triggers + +### Standard Triggers +- User corrections ("No, that's wrong...") +- Command failures (non-zero exit codes) +- API errors +- Knowledge gaps + +### OpenClaw-Specific Triggers + +| Trigger | Action | +|---------|--------| +| Tool call error | Log to TOOLS.md with tool name | +| Session handoff confusion | Log to AGENTS.md with delegation pattern | +| Model behavior surprise | Log to SOUL.md with expected vs actual | +| Skill issue | Log to .learnings/ or report upstream | + +## Verification + +Check hook is registered: + +```bash +openclaw hooks list +``` + +Check skill is loaded: + +```bash +openclaw status +``` + +## Troubleshooting + +### Hook not firing + +1. Ensure hooks enabled in config +2. Restart gateway after config changes +3. Check gateway logs for errors + +### Learnings not persisting + +1. Verify `.learnings/` directory exists +2. Check file permissions +3. Ensure workspace path is configured correctly + +### Skill not loading + +1. Check skill is in skills directory +2. Verify SKILL.md has correct frontmatter +3. Run `openclaw status` to see loaded skills diff --git a/skills/self-improving-agent/scripts/activator.sh b/skills/self-improving-agent/scripts/activator.sh new file mode 100644 index 0000000..29eec22 --- /dev/null +++ b/skills/self-improving-agent/scripts/activator.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Self-Improvement Activator Hook +# Triggers on UserPromptSubmit to remind Claude about learning capture +# Keep output minimal (~50-100 tokens) to minimize overhead + +set -e + +# Output reminder as system context +cat << 'EOF' + +After completing this task, evaluate if extractable knowledge emerged: +- Non-obvious solution discovered through investigation? +- Workaround for unexpected behavior? +- Project-specific pattern learned? +- Error required debugging to resolve? + +If yes: Log to .learnings/ using the self-improvement skill format. +If high-value (recurring, broadly applicable): Consider skill extraction. + +EOF diff --git a/skills/self-improving-agent/scripts/error-detector.sh b/skills/self-improving-agent/scripts/error-detector.sh new file mode 100644 index 0000000..3c310dd --- /dev/null +++ b/skills/self-improving-agent/scripts/error-detector.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Self-Improvement Error Detector Hook +# Triggers on PostToolUse for Bash to detect command failures +# Reads CLAUDE_TOOL_OUTPUT environment variable + +set -e + +# Check if tool output indicates an error +# CLAUDE_TOOL_OUTPUT contains the result of the tool execution +OUTPUT="${CLAUDE_TOOL_OUTPUT:-}" + +# Patterns indicating errors (case-insensitive matching) +ERROR_PATTERNS=( + "error:" + "Error:" + "ERROR:" + "failed" + "FAILED" + "command not found" + "No such file" + "Permission denied" + "fatal:" + "Exception" + "Traceback" + "npm ERR!" + "ModuleNotFoundError" + "SyntaxError" + "TypeError" + "exit code" + "non-zero" +) + +# Check if output contains any error pattern +contains_error=false +for pattern in "${ERROR_PATTERNS[@]}"; do + if [[ "$OUTPUT" == *"$pattern"* ]]; then + contains_error=true + break + fi +done + +# Only output reminder if error detected +if [ "$contains_error" = true ]; then + cat << 'EOF' + +A command error was detected. Consider logging this to .learnings/ERRORS.md if: +- The error was unexpected or non-obvious +- It required investigation to resolve +- It might recur in similar contexts +- The solution could benefit future sessions + +Use the self-improvement skill format: [ERR-YYYYMMDD-XXX] + +EOF +fi diff --git a/skills/self-improving-agent/scripts/extract-skill.sh b/skills/self-improving-agent/scripts/extract-skill.sh new file mode 100644 index 0000000..ccae55a --- /dev/null +++ b/skills/self-improving-agent/scripts/extract-skill.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Skill Extraction Helper +# Creates a new skill from a learning entry +# Usage: ./extract-skill.sh [--dry-run] + +set -e + +# Configuration +SKILLS_DIR="./skills" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +usage() { + cat << EOF +Usage: $(basename "$0") [options] + +Create a new skill from a learning entry. + +Arguments: + skill-name Name of the skill (lowercase, hyphens for spaces) + +Options: + --dry-run Show what would be created without creating files + --output-dir Relative output directory under current path (default: ./skills) + -h, --help Show this help message + +Examples: + $(basename "$0") docker-m1-fixes + $(basename "$0") api-timeout-patterns --dry-run + $(basename "$0") pnpm-setup --output-dir ./skills/custom + +The skill will be created in: \$SKILLS_DIR// +EOF +} + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +# Parse arguments +SKILL_NAME="" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + --output-dir) + if [ -z "${2:-}" ] || [[ "${2:-}" == -* ]]; then + log_error "--output-dir requires a relative path argument" + usage + exit 1 + fi + SKILLS_DIR="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + log_error "Unknown option: $1" + usage + exit 1 + ;; + *) + if [ -z "$SKILL_NAME" ]; then + SKILL_NAME="$1" + else + log_error "Unexpected argument: $1" + usage + exit 1 + fi + shift + ;; + esac +done + +# Validate skill name +if [ -z "$SKILL_NAME" ]; then + log_error "Skill name is required" + usage + exit 1 +fi + +# Validate skill name format (lowercase, hyphens, no spaces) +if ! [[ "$SKILL_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + log_error "Invalid skill name format. Use lowercase letters, numbers, and hyphens only." + log_error "Examples: 'docker-fixes', 'api-patterns', 'pnpm-setup'" + exit 1 +fi + +# Validate output path to avoid writes outside current workspace. +if [[ "$SKILLS_DIR" = /* ]]; then + log_error "Output directory must be a relative path under the current directory." + exit 1 +fi + +if [[ "$SKILLS_DIR" =~ (^|/)\.\.(/|$) ]]; then + log_error "Output directory cannot include '..' path segments." + exit 1 +fi + +SKILLS_DIR="${SKILLS_DIR#./}" +SKILLS_DIR="./$SKILLS_DIR" + +SKILL_PATH="$SKILLS_DIR/$SKILL_NAME" + +# Check if skill already exists +if [ -d "$SKILL_PATH" ] && [ "$DRY_RUN" = false ]; then + log_error "Skill already exists: $SKILL_PATH" + log_error "Use a different name or remove the existing skill first." + exit 1 +fi + +# Dry run output +if [ "$DRY_RUN" = true ]; then + log_info "Dry run - would create:" + echo " $SKILL_PATH/" + echo " $SKILL_PATH/SKILL.md" + echo "" + echo "Template content would be:" + echo "---" + cat << TEMPLATE +name: $SKILL_NAME +description: "[TODO: Add a concise description of what this skill does and when to use it]" +--- + +# $(echo "$SKILL_NAME" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') + +[TODO: Brief introduction explaining the skill's purpose] + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| [Trigger condition] | [What to do] | + +## Usage + +[TODO: Detailed usage instructions] + +## Examples + +[TODO: Add concrete examples] + +## Source Learning + +This skill was extracted from a learning entry. +- Learning ID: [TODO: Add original learning ID] +- Original File: .learnings/LEARNINGS.md +TEMPLATE + echo "---" + exit 0 +fi + +# Create skill directory structure +log_info "Creating skill: $SKILL_NAME" + +mkdir -p "$SKILL_PATH" + +# Create SKILL.md from template +cat > "$SKILL_PATH/SKILL.md" << TEMPLATE +--- +name: $SKILL_NAME +description: "[TODO: Add a concise description of what this skill does and when to use it]" +--- + +# $(echo "$SKILL_NAME" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') + +[TODO: Brief introduction explaining the skill's purpose] + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| [Trigger condition] | [What to do] | + +## Usage + +[TODO: Detailed usage instructions] + +## Examples + +[TODO: Add concrete examples] + +## Source Learning + +This skill was extracted from a learning entry. +- Learning ID: [TODO: Add original learning ID] +- Original File: .learnings/LEARNINGS.md +TEMPLATE + +log_info "Created: $SKILL_PATH/SKILL.md" + +# Suggest next steps +echo "" +log_info "Skill scaffold created successfully!" +echo "" +echo "Next steps:" +echo " 1. Edit $SKILL_PATH/SKILL.md" +echo " 2. Fill in the TODO sections with content from your learning" +echo " 3. Add references/ folder if you have detailed documentation" +echo " 4. Add scripts/ folder if you have executable code" +echo " 5. Update the original learning entry with:" +echo " **Status**: promoted_to_skill" +echo " **Skill-Path**: skills/$SKILL_NAME"