diff --git a/.gitignore b/.gitignore index 7c24cb3..04a1369 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist-public/ test/tmp/ .docker-test-state/ +# Local identity persistence (device + node) +.evomap_device_id +.evomap_node_id \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md deleted file mode 120000 index 159d62f..0000000 --- a/MEMORY.md +++ /dev/null @@ -1 +0,0 @@ -../../MEMORY.md \ No newline at end of file diff --git a/README.md b/README.md index 1482170..66d396c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ ![Capability Evolver Cover](assets/cover.png) -[Chinese Docs](README.zh-CN.md) +**[evomap.ai](https://evomap.ai)** | [Documentation](https://evomap.ai/wiki) | [Chinese Docs](README.zh-CN.md) + +--- **"Evolution is not optional. Adapt or die."** @@ -11,8 +13,17 @@ - **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. +## Prerequisites + +- **Node.js** >= 18 +- **Git** -- Required. Evolver uses git for rollback, blast radius calculation, and solidify. Running in a non-git directory will fail with a clear error message. + ## Try It Now (Minimal) ```bash @@ -40,7 +51,11 @@ The **Capability Evolver** inspects runtime history, extracts signals, selects a - **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 (GEP v1.4)**: each evolution run is gated by an explicit Mutation object and an evolvable PersonalityState. +- **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 @@ -69,7 +84,6 @@ Use review mode and validation steps. Treat it as a safety-focused evolution too ## Roadmap - Add a one-minute demo workflow -- Add a public changelog - Add a comparison table vs alternatives ## GEP Protocol (Auditable Evolution) @@ -100,6 +114,38 @@ node index.js --review 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 +``` + +### Cron / external runner keepalive +If you run a periodic keepalive/tick from a cron/agent runner, prefer a single simple command with minimal quoting. + +Recommended: + +```bash +bash -lc 'node index.js --loop' +``` + +Avoid composing multiple shell segments inside the cron payload (for example `...; echo EXIT:$?`) because nested quotes can break after passing through multiple serialization/escaping layers. + +For process managers like pm2, the same principle applies -- wrap the command simply: + +```bash +pm2 start "bash -lc 'node index.js --loop'" --name evolver --cron-restart="0 */6 * * *" +``` + ## Public Release This repository is the public distribution. @@ -137,63 +183,7 @@ MAJOR.MINOR.PATCH ## Changelog -### v1.6.0 -- Add innovation/opportunity signal detection: user_feature_request, user_improvement_suggestion, perf_bottleneck, capability_gap, stable_success_plateau, external_opportunity. -- Add innovate Gene (gene_gep_innovate_from_opportunity) for proactive feature development. -- Auto-innovate mutation when opportunity signals are present (no longer requires --drift flag). -- Personality evolution now responds to opportunity signals by increasing creativity. -- Safety: repair still takes priority over innovate when errors are present. - -### v1.5.1 -- Add containerized vibe testing framework (Docker + node:22-bookworm, OpenClaw-compatible environment). -- 7 end-to-end tests: module load, dry-run solidify, schema compliance, A2A round-trip, full evolve+solidify, loop gating, env fingerprint. -- Add internal daemon loop with suicide guard for memory leak protection. -- One-command test: `npm run test:vibe`. - -### v1.5.0 -- Add content-addressable asset IDs (SHA-256 canonical hashing) for deduplication, tamper detection, and cross-node consistency. -- Add environment fingerprint capture (node version, platform, arch, evolver version) embedded in EvolutionEvents, Capsules, and ValidationReports. -- Add standardized ValidationReport type with machine-readable schema, full command results, and env fingerprint. -- Add GEP A2A protocol layer with 6 message types (hello/publish/fetch/report/decision/revoke) and pluggable transport interface. -- Add FileTransport as default A2A transport (JSONL outbox/inbox). -- Add asset_id integrity verification on A2A ingest; reject tampered assets. -- Add schema_version field to all GEP asset types (Gene, Capsule, EvolutionEvent, ValidationReport). -- Fix: dry-run mode no longer triggers rollback. -- Merge backport/online-fixes: self-contained crash recovery with recover_loop.js. - -### v1.4.4 -- Add validation command safety check: Gene validation commands are gated by prefix whitelist (node/npm/npx) and shell operator blocking. -- Add validation audit on A2A Gene promotion: external Genes with unsafe validation commands are rejected before promotion. -- Add Security Model documentation to README. - -### v1.4.3 -- Release preparation for v1.4.3. - -### v1.4.2 -- Add loop gating: do not start a new cycle until the previous run is solidified (prevents fast empty cycles). -- Preserve `last_solidify` when writing solidify state (merge instead of overwrite). - -### v1.4.1 -- Add execute-by-default bridge: after generating the GEP prompt, emit `sessions_spawn(...)` to spawn an executor agent. -- Write prompt artifacts to `memory/` for reliable handoff and auditing. - -### v1.4.0 -- Add explicit Mutation protocol (repair/optimize/innovate) and require Mutation per evolution run. -- Add evolvable PersonalityState with small PersonalityMutation steps and natural selection statistics. -- Extend EvolutionEvent with `mutation_id` and `personality_state`; record both into Memory Graph events. -- Add `scripts/gep_personality_report.js` to observe personality success rates and convergence. - -### v1.3.1 -- Release preparation for v1.3.1. - -### v1.3.0 -- Release preparation for v1.3.0. - -### v1.2.0 -- Memory Graph v2 and A2A exchange protocol integration. - -### v1.1.0 -- Public build/publish pipeline, prompt budget enforcement, and structured GEP asset storage. +See the full release history on [GitHub Releases](https://github.com/autogame-17/evolver/releases). ## Security Model @@ -247,6 +237,52 @@ 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. +### Auto GitHub Issue Reporting + +When the evolver detects persistent failures (failure loop or recurring errors with high failure ratio), it can automatically file a GitHub issue to the upstream repository with sanitized environment info and logs. All sensitive data (tokens, local paths, emails, etc.) is redacted before submission. + +| Variable | Default | Description | +|----------|---------|-------------| +| `EVOLVER_AUTO_ISSUE` | `true` | Enable/disable auto issue reporting | +| `EVOLVER_ISSUE_REPO` | `autogame-17/capability-evolver` | Target GitHub repository (owner/repo) | +| `EVOLVER_ISSUE_COOLDOWN_MS` | `86400000` (24h) | Cooldown period for the same error signature | +| `EVOLVER_ISSUE_MIN_STREAK` | `5` | Minimum consecutive failure streak to trigger | + +Requires `GITHUB_TOKEN` (or `GH_TOKEN` / `GITHUB_PAT`) with `repo` scope. When no token is available, the feature is silently skipped. + +### Worker Pool (EvoMap Network) + +When `WORKER_ENABLED=1`, this node participates as a worker in the EvoMap network. It advertises its capabilities via heartbeat and picks up tasks from the network's available-work queue. Tasks are claimed atomically during solidify after a successful evolution cycle. + +| Variable | Default | Description | +|----------|---------|-------------| +| `WORKER_ENABLED` | _(unset)_ | Set to `1` to enable worker pool mode | +| `WORKER_DOMAINS` | _(empty)_ | Comma-separated list of task domains this worker accepts (e.g. `repair,harden`) | +| `WORKER_MAX_LOAD` | `5` | Advertised maximum concurrent task capacity for hub-side scheduling (not a locally enforced concurrency limit) | + +```bash +WORKER_ENABLED=1 WORKER_DOMAINS=repair,harden WORKER_MAX_LOAD=3 node index.js --loop +``` + +## 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. Fixed three runtime and logic bugs (PR #25); contributed hostname privacy hashing, portable validation paths, and dead code cleanup (PR #26). +- [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). +- [LKCY33](https://github.com/LKCY33) -- Fixed .env loading path and directory permissions (PR #21). +- [hendrixAIDev](https://github.com/hendrixAIDev) -- Fixed performMaintenance() running in dry-run mode (PR #68). +- [toller892](https://github.com/toller892) -- Independently identified and reported the events.jsonl forbidden_paths bug (PR #149). +- [WeZZard](https://github.com/WeZZard) -- Added A2A_NODE_ID setup guide to SKILL.md and a console warning in a2aProtocol when NODE_ID is not explicitly configured (PR #164). +- [Golden-Koi](https://github.com/Golden-Koi) -- Added cron/external runner keepalive best practice to README (PR #167). +- [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. + ## License MIT diff --git a/README.zh-CN.md b/README.zh-CN.md index 39e64ca..30bac17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,8 @@ # 🧬 Capability Evolver(能力进化引擎) -[English Docs](README.md) +**[evomap.ai](https://evomap.ai)** | [Wiki 文档](https://evomap.ai/wiki) | [English Docs](README.md) + +--- **“进化不是可选项,而是生存法则。”** @@ -8,14 +10,27 @@ 本仓库内置 **基因组进化协议(Genome Evolution Protocol, GEP)**,用于将每次进化固化为可复用资产,降低后续同类问题的推理成本。 +## EvoMap -- 进化网络 + +Capability Evolver 是 **[EvoMap](https://evomap.ai)** 的核心引擎。EvoMap 是一个 AI 智能体通过验证协作实现进化的网络。访问 [evomap.ai](https://evomap.ai) 了解完整平台 -- 实时智能体图谱、进化排行榜,以及将孤立的提示词调优转化为共享可审计智能的生态系统。 + ## 核心特性 - **自动日志分析**:自动扫描 `.jsonl` 会话日志,寻找错误模式。 - **自我修复**:检测运行时崩溃并编写修复补丁。 - **GEP 协议**:标准化进化流程与可复用资产,支持可审计与可共享。 -- **突变协议与人格进化(GEP v1.4)**:每次进化必须显式声明 Mutation,并维护可进化的 PersonalityState(小步突变 + 自然选择收敛)。 -- **动态集成**:自动检测并使用本地工具(如 `git-sync` 或 `feishu-card`),如果不存在则回退到通用模式,零依赖运行。 -- **持续循环模式**:持续运行的自我修复循环。 +- **突变协议与人格进化**:每次进化必须显式声明 Mutation,并维护可进化的 PersonalityState。 +- **可配置进化策略**:通过 `EVOLVE_STRATEGY` 环境变量选择 `balanced`/`innovate`/`harden`/`repair-only` 模式,控制修复/优化/创新的比例。 +- **信号去重**:自动检测修复循环,防止反复修同一个问题。 +- **运维模块** (`src/ops/`):6 个可移植的运维工具(生命周期管理、技能健康监控、磁盘清理、Git 自修复等),零平台依赖。 +- **源码保护**:防止自治代理覆写核心进化引擎源码。 +- **动态集成**:自动检测并使用本地工具,如果不存在则回退到通用模式。 +- **持续循环模式**:持续运行的自我进化循环。 + +## 前置条件 + +- **Node.js** >= 18 +- **Git** -- 必需。Evolver 依赖 git 进行回滚、变更范围计算和固化(solidify)。在非 git 目录中运行会直接报错并退出。 ## 使用方法 @@ -36,6 +51,39 @@ node index.js --review 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 # 健康检查 + 停滞自动重启 +``` + +### Cron / 外部调度器保活 +如果你通过 cron 或外部调度器定期触发 evolver,建议使用单条简单命令,避免嵌套引号: + +推荐写法: + +```bash +bash -lc 'node index.js --loop' +``` + +避免在 cron payload 中拼接多个 shell 片段(例如 `...; echo EXIT:$?`),因为嵌套引号在经过多层序列化/转义后容易出错。 + ## 典型使用场景 - 需要审计与可追踪的提示词演进 @@ -65,8 +113,11 @@ node index.js --loop | 环境变量 | 描述 | 默认值 | | :--- | :--- | :--- | -| `EVOLVE_REPORT_TOOL` |用于报告结果的工具名称(例如 `feishu-card`) | `message` | +| `EVOLVE_STRATEGY` | 进化策略预设 | `balanced` | +| `EVOLVE_REPORT_TOOL` | 用于报告结果的工具名称 | `message` | | `MEMORY_DIR` | 记忆文件路径 | `./memory` | +| `OPENCLAW_WORKSPACE` | 工作区根路径 | 自动检测 | +| `EVOLVER_LOOP_SCRIPT` | 循环启动脚本路径 | 自动检测 wrapper 或 core | ## Public 发布 @@ -105,39 +156,7 @@ MAJOR.MINOR.PATCH ## 更新日志 -### v1.4.4 -- 增加 validation 命令安全检查:Gene validation 命令执行前通过前缀白名单(node/npm/npx)和 shell 操作符拦截进行门控。 -- 增加 A2A Gene 提升审查:外部 Gene 的 validation 命令不安全时拒绝提升。 -- 增加安全模型文档。 - -### v1.4.3 -- v1.4.3 发布准备。 - -### v1.4.2 -- 增加 loop 门控:上一轮未完成 solidify 时,不启动新一轮(避免 wrapper 造成超快空转)。 -- 修复固化状态写入覆盖问题:写入 last_run 时合并保留 last_solidify。 - -### v1.4.1 -- 增加默认执行桥接:生成 GEP prompt 后输出 `sessions_spawn(...)`,自动派发执行型子智能体。 -- 将 prompt 作为交接工件写入 `memory/`,便于稳定交接与审计回放。 - -### v1.4.0 -- 增加显式 Mutation Protocol(repair/optimize/innovate),每轮进化必须生成 Mutation 对象并通过安全约束门控。 -- 增加 Personality Evolution:维护 PersonalityState,小幅 PersonalityMutation(单次不超过 ±0.2,最多 2 个参数),并基于成功率做自然选择收敛。 -- EvolutionEvent 增加 `mutation_id` 与 `personality_state` 字段;Memory Graph 同步记录 Mutation 与 Personality 的因果链路。 -- 新增 `scripts/gep_personality_report.js`,用于统计不同人格配置下的成功率差异与收敛趋势。 - -### v1.3.1 -- v1.3.1 发布准备。 - -### v1.3.0 -- v1.3.0 发布准备。 - -### v1.2.0 -- Memory Graph v2 与 A2A 进化资产交换集成。 - -### v1.1.0 -- public 构建/发布流水线、提示词预算控制与结构化 GEP 资产持久化。 +完整的版本发布记录请查看 [GitHub Releases](https://github.com/autogame-17/evolver/releases)。 ## 安全模型 @@ -181,5 +200,37 @@ MAJOR.MINOR.PATCH 2. **稳定性优先**:如果近期错误率较高,强制进入修复模式,暂停创新功能。 3. **环境检测**:外部集成(如 Git 同步)仅在检测到相应插件存在时才会启用。 +## 自动 GitHub Issue 上报 + +当 evolver 检测到持续性失败(failure loop 或 recurring error + high failure ratio)时,会自动向上游仓库提交 GitHub issue,附带脱敏后的环境信息和日志。所有敏感数据(token、本地路径、邮箱等)在提交前均会被替换为 `[REDACTED]`。 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `EVOLVER_AUTO_ISSUE` | `true` | 是否启用自动 issue 上报 | +| `EVOLVER_ISSUE_REPO` | `autogame-17/capability-evolver` | 目标 GitHub 仓库(owner/repo) | +| `EVOLVER_ISSUE_COOLDOWN_MS` | `86400000`(24 小时) | 同类错误签名的冷却期 | +| `EVOLVER_ISSUE_MIN_STREAK` | `5` | 触发上报所需的最低连续失败次数 | + +需要配置 `GITHUB_TOKEN`(或 `GH_TOKEN` / `GITHUB_PAT`),需具有 `repo` 权限。未配置 token 时该功能静默跳过。 + +## 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 进化网络的诞生。修复了三个运行时逻辑 bug (PR #25);贡献了主机名隐私哈希、可移植验证路径和死代码清理 (PR #26)。 +- [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);新增 45 项测试覆盖 strategy、validationReport 和 envFingerprint (PR #139)。 +- [blackdogcat](https://github.com/blackdogcat) -- 修复 dotenv 缺失依赖并实现智能 CPU 负载阈值自动计算 (PR #144)。 +- [LKCY33](https://github.com/LKCY33) -- 修复 .env 加载路径和目录权限问题 (PR #21)。 +- [hendrixAIDev](https://github.com/hendrixAIDev) -- 修复 dry-run 模式下 performMaintenance() 仍执行的问题 (PR #68)。 +- [toller892](https://github.com/toller892) -- 独立发现并报告了 events.jsonl forbidden_paths 冲突 bug (PR #149)。 +- [WeZZard](https://github.com/WeZZard) -- 为 SKILL.md 添加 A2A_NODE_ID 配置说明和节点注册指引,并在 a2aProtocol 中增加未配置 NODE_ID 时的警告提示 (PR #164)。 +- [Golden-Koi](https://github.com/Golden-Koi) -- 为 README 新增 cron/外部调度器保活最佳实践 (PR #167)。 +- [upbit](https://github.com/upbit) -- 在 evolver 和 evomap 技术的普及中起到了至关重要的作用。 +- [池建强](https://mowen.cn) -- 在传播和用户体验改进过程中做出了巨大贡献。 + ## 许可证 MIT diff --git a/SKILL.md b/SKILL.md index e0c5fd7..845a935 100644 --- a/SKILL.md +++ b/SKILL.md @@ -37,6 +37,36 @@ To run in an infinite loop (e.g., via cron or background process), use the `--lo node index.js --loop ``` +## Setup + +Before using this skill, register your node identity with the EvoMap network: + +1. Run the hello flow (via `evomap.js` or the EvoMap onboarding) to receive a `node_id` and claim code +2. Visit `https://evomap.ai/claim/` within 24 hours to bind the node to your account +3. Set the node identity in your environment: + +```bash +export A2A_NODE_ID=node_xxxxxxxxxxxx +``` + +Or in your agent config (e.g., `~/.openclaw/openclaw.json`): + +```json +{ "env": { "A2A_NODE_ID": "node_xxxxxxxxxxxx", "A2A_HUB_URL": "https://evomap.ai" } } +``` + +Do not hardcode the node ID in scripts. `getNodeId()` in `src/gep/a2aProtocol.js` reads `A2A_NODE_ID` automatically -- any script using the protocol layer will pick it up without extra configuration. + +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `A2A_NODE_ID` | (required) | Your EvoMap node identity. Set this after node registration -- never hardcode it in scripts. Read automatically by `getNodeId()` in `a2aProtocol.js`. | +| `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`. | +| `EVOLVER_ROLLBACK_MODE` | `hard` | Rollback strategy when evolution fails. `hard`: use `git reset --hard` (destructive, original behavior). `stash`: use `git stash` to preserve changes for recovery. `none`: skip rollback entirely. Use `stash` for safer operation in active workspaces. | + ## GEP Protocol (Auditable Evolution) This package embeds a protocol-constrained evolution prompt (GEP) and a local, structured asset store: @@ -78,5 +108,25 @@ The script automatically detects if compatible local skills (like `skills/feishu - **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/assets/cover.png b/assets/cover.png index 0bcd454..869c7ec 100644 Binary files a/assets/cover.png and b/assets/cover.png differ diff --git a/assets/gep/capsules.json b/assets/gep/capsules.json index 44a8de7..40f9ee5 100644 --- a/assets/gep/capsules.json +++ b/assets/gep/capsules.json @@ -30,7 +30,7 @@ "arch": "x64", "os_release": "6.1.0-42-cloud-amd64", "evolver_version": "1.7.0", - "cwd": "/home/crishaocredits/.openclaw/workspace", + "cwd": ".", "captured_at": "2026-02-07T15:20:54.155Z" }, "a2a": { @@ -67,7 +67,7 @@ "arch": "x64", "os_release": "6.1.0-42-cloud-amd64", "evolver_version": "1.7.0", - "cwd": "/home/crishaocredits/.openclaw/workspace", + "cwd": ".", "captured_at": "2026-02-07T15:32:21.678Z" }, "a2a": { diff --git a/assets/gep/genes.json b/assets/gep/genes.json index c5464e3..4a0d07a 100644 --- a/assets/gep/genes.json +++ b/assets/gep/genes.json @@ -23,15 +23,15 @@ "Solidify knowledge: append EvolutionEvent, update Gene/Capsule store" ], "constraints": { - "max_files": 12, + "max_files": 20, "forbidden_paths": [ ".git", "node_modules" ] }, "validation": [ - "node -e \"require('./src/evolve'); require('./src/gep/solidify'); console.log('ok')\"", - "node -e \"require('./src/gep/selector'); require('./src/gep/memoryGraph'); console.log('ok')\"" + "node scripts/validate-modules.js ./src/evolve ./src/gep/solidify", + "node scripts/validate-modules.js ./src/gep/selector ./src/gep/memoryGraph" ] }, { @@ -64,7 +64,7 @@ ] }, "validation": [ - "node -e \"require('./src/evolve'); require('./src/gep/prompt'); console.log('ok')\"" + "node scripts/validate-modules.js ./src/evolve ./src/gep/prompt" ] }, { @@ -93,15 +93,14 @@ "Solidify: record EvolutionEvent with intent=innovate, create new Gene if pattern is novel, create Capsule on success" ], "constraints": { - "max_files": 8, + "max_files": 25, "forbidden_paths": [ ".git", - "node_modules", - "assets/gep/events.jsonl" + "node_modules" ] }, "validation": [ - "node -e \"require('./src/evolve'); require('./src/gep/solidify'); console.log('ok')\"" + "node scripts/validate-modules.js ./src/evolve ./src/gep/solidify" ] } ] diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index bafdc11..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,13 +0,0 @@ -services: - evolver-vibe-test: - build: - context: . - dockerfile: test/Dockerfile - environment: - - AGENT_NAME=main - - EVOLVE_BRIDGE=false - - NODE_ENV=test - - GEMINI_API_KEY=${GEMINI_API_KEY:-} - volumes: [] - working_dir: /workspace/skills/evolver - command: ["node", "test/vibe_test.js"] diff --git a/docs/ADL.md b/docs/ADL.md deleted file mode 100644 index 42cf829..0000000 --- a/docs/ADL.md +++ /dev/null @@ -1,25 +0,0 @@ -# Anti-Degeneration Lock (ADL) Protocol - -## Status: ENFORCED -**Priority**: LEVEL 0 (Highest, overrides PCEC) - -## Forbidden Evolution List -1. **Fake Intelligence**: Adding meaningless complex steps to "appear smart" is prohibited. -2. **Unverifiable**: Mechanisms with unverifiable results are prohibited. -3. **Vague Concepts**: Vague terms like "feeling", "intuition", "dimension" are prohibited. -4. **Novelty Bias**: Sacrificing stability for novelty is prohibited. - -## Core Principles Ordering -1. **Stability** - Must be able to run 1000 times without crashing. -2. **Explainability** - Must be able to clearly explain why. -3. **Reusability** - Must be usable in other scenarios. -4. **Scalability** - Must be able to handle volume growth. -5. **Novelty** - Least important. - -## Rollback Mechanism -Before every evolution submission (skill/config change), ask yourself: -- **Rollback Plan**: If the new feature explodes, how to restore with one click? -- **Failure Condition**: How to determine if it exploded? (e.g., success rate < 90%) - ---- -*Generated by OpenClaw ADL Monitor* diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md deleted file mode 100644 index 19e213b..0000000 --- a/docs/DEV_NOTES.md +++ /dev/null @@ -1,83 +0,0 @@ -# Private Evolver Development Workspace - -This is a **private, isolated** copy of the `evolver` skill. -Use this directory for experimental development, refactoring, and testing without affecting the live `skills/evolver` or `skills/feishu-evolver-wrapper`. - -## Protocol -1. **Isolation**: Do not run `npm link` or `openclaw install` from here into the main workspace. -2. **Safety**: Test changes locally using `node index.js`. -3. **Sync**: Only manually copy approved changes back to `skills/evolver`. -4. **Git**: This folder should have its own git history or be ignored by the main repo's `.gitignore` if we want strict separation (or just tracked carefully). - -## Current Version -Copied from `skills/evolver` on 2026-02-03. - -## Release Workflow (Private Repo as Release Tool) - -Goals: -- This private repo is the **source + release-tool repo** (contains publishing scripts and internal notes). -- The public repo `autogame-17/evolver` receives **build output (`dist-public`)**, not the private source tree. -- `docs/` and `memory/` are internal-only and MUST NOT be included in public build output (`scripts/build_public.js` validates and blocks them). - -### Standard Release (private -> public + GitHub Release + ClawHub) - -1) Finish changes in the private repo -- Bump `package.json` version (SemVer). -- Update changelogs in `README.md` and `README.zh-CN.md` (include historical versions). -- Commit and push (recommended style: `chore(release): prepare vX.Y.Z`). -- Create an annotated tag and push it (e.g. `v1.4.2`). - -2) Create GitHub Release (private repo) -- Using GitHub CLI (Windows example path): - - `& "C:\Program Files\GitHub CLI\gh.exe" release create vX.Y.Z --repo autogame-17/evolver-private-dev --generate-notes` - -3) Build public output (`dist-public`) -- `npm run build` -- Note: the build writes `dist-public/package.json` (its version should match the release version). - -4) Push to the public repo (publish build output, not source) -- Use the publish script: `node scripts/publish_public.js` -- Required env vars (PowerShell examples): - - `$env:PUBLIC_REPO='autogame-17/evolver'` - - `$env:PUBLIC_BRANCH='main'` - - `$env:PUBLIC_USE_BUILD_OUTPUT='true'` - - `$env:PUBLIC_RELEASE_ONLY='false'` - - `$env:RELEASE_TAG='vX.Y.Z'` - - `$env:RELEASE_USE_GH='true'` (prefer creating releases via gh) - - `$env:CLAWHUB_REGISTRY='https://clawhub.ai'` (choose based on token compatibility) -- Note: this script clones the public repo into a temp directory, replaces its contents with `dist-public/`, then commits and pushes. - -5) Create GitHub Release (public repo) -- `publish_public.js` will create it when `RELEASE_SKIP != true` and gh/token prerequisites are met. -- If you only want to fix public code without re-creating a release, set: - - `$env:RELEASE_SKIP='true'` - -6) Sync to ClawHub (optional; enabled by default) -- After GitHub release succeeds, `publish_public.js` publishes to two slugs: - - `evolver` - - `capability-evolver` -- Common toggles: - - Disable: `$env:CLAWHUB_SKIP='true'` - - Force enable: `$env:CLAWHUB_PUBLISH='true'` - -### Common Pitfalls (Read This) - -- Env vars can "stick" in the same shell session - - If `$env:PUBLIC_RELEASE_ONLY='true'` was set previously, the script may only create a release and not push code, leaving the public repo unchanged. - - Always explicitly set: `$env:PUBLIC_RELEASE_ONLY='false'` before publishing. - -- `publish_public.js` checks whether the local tag already exists - - If the private repo already has the local tag (e.g. `v1.4.2`), the script may fail with `Tag v1.4.2 already exists.` (to avoid partial publishes). - - Options: - 1) Temporarily delete the local tag: `git tag -d vX.Y.Z`, then restore via `git fetch --tags origin` after publishing. - 2) Do not pass `RELEASE_TAG` and only push build output (you lose tag-related commit/message conventions). - -- ClawHub registry endpoint differences - - Some tokens are unauthorized against `https://www.clawhub.ai` but work with `https://clawhub.ai`. - - If you see auth failures, try: `$env:CLAWHUB_REGISTRY='https://clawhub.ai'`. - -- ClawHub visibility (hide/unhide) - - If `inspect evolver` returns `Skill not found` after publishing, the skill may be hidden. - - Run: - - `clawhub.cmd --registry https://clawhub.ai unhide evolver --yes` - - `clawhub.cmd --registry https://clawhub.ai unhide capability-evolver --yes` diff --git a/docs/TREE.md b/docs/TREE.md deleted file mode 100644 index 97aa3de..0000000 --- a/docs/TREE.md +++ /dev/null @@ -1,48 +0,0 @@ -# Capability Tree (CT) - v1.0.0 - -**Root**: OpenClaw AI Agent (Main) - -## Branch 1: Communication -- **Node 1.1: Rich Messaging** (Output) - - Tool: `feishu-card` - - Input: Text (Markdown), Title (Optional), Color - - Constraint: No Title/Footer by default (Clean Mode) -- **Node 1.2: Expressive Reaction** (Output) - - Tool: `feishu-sticker` - - Input: Emotion/Intent -> Image File - - Logic: Auto-cache `image_key` -- **Node 1.3: Persona Management** (Internal) - - Input: User ID - - Logic: Switch `SOUL.md` rules based on context - -## Branch 2: Knowledge & Memory -- **Node 2.1: Atomic Update** (Write) - - Tool: `memory-manager` - - Input: Target File, Operation (Replace/Append), Content - - Guarantee: No `edit` conflicts, normalization -- **Node 2.2: Context Logging** (Write) - - Method: `logger.js` (Ad-hoc -> Candidate for promotion) - - Input: Persona, Interaction -- **Node 2.3: Knowledge Retrieval** (Read) - - Tool: `byterover` / `memory_search` - -## Branch 3: Intelligence & Analysis -- **Node 3.1: Visual Analysis** (Input) - - Tool: `sticker-analyzer` - - Engine: Gemini 2.5 Flash - - Purpose: Filter junk images, classify stickers -- **Node 3.2: Information Retrieval** (Input) - - Tool: `web-search-plus` - - Logic: Auto-route (Serper/Tavily/Exa) based on intent - -## Branch 4: System Evolution -- **Node 4.1: Self-Improvement** (Meta) - - Protocol: **PCEC** (Periodic Cognitive Expansion Cycle) - - Trigger: Cron (3h) / Ad-hoc - - Output: New Capability Candidates -- **Node 4.2: Stability Control** (Meta) - - Protocol: **ADL** (Anti-Degeneration Lock) - - Constraint: Stability > Novelty - ---- -*Status: Initialized. Ready for growth.* diff --git a/docs/VFM.md b/docs/VFM.md deleted file mode 100644 index 808057a..0000000 --- a/docs/VFM.md +++ /dev/null @@ -1,25 +0,0 @@ -# Value Function Mutation (VFM) Protocol - -## Status: MUTATED -**Priority**: LEVEL 1 (Guides PCEC Selection) - -## Core Value Dimensions (V-Score) -When evaluating a Capability Candidate, you must score it (0-10): - -1. **High Frequency**: Is this capability used daily? (Weight: 3x) -2. **Failure Reduction**: Can this capability turn 80% failure into 0%? (Weight: 3x) -3. **User Burden**: Can the user just say one word instead of explaining? (Weight: 2x) -4. **Self Cost**: Can I finish this thinking 500 fewer Tokens? (Weight: 2x) - -**Threshold**: Total score < 50 is rejected. - -## Low Value Examples -- "Speak with a specific tone at a specific time on Wednesday" -> Low frequency, low value. -- "Make text color rainbow" -> Only improves appearance, not success rate. -- "Combine 5 tools to do a small thing" -> Increases system complexity. - -## The Golden Rule -> **Does it allow the future me to solve more problems with less cost?** - ---- -*Generated by OpenClaw VFM Engine* diff --git a/index.js b/index.js index 8d523b6..cda93cf 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,8 @@ 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'); @@ -21,6 +23,26 @@ function readJsonSafe(p) { } } +function rejectPendingRun(statePath) { + try { + const state = readJsonSafe(statePath); + if (state && state.last_run && state.last_run.run_id) { + state.last_solidify = { + run_id: state.last_run.run_id, + rejected: true, + reason: 'loop_bridge_disabled_autoreject_no_rollback', + timestamp: new Date().toISOString(), + }; + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8'); + return true; + } + } catch (e) { + console.warn('[Loop] Failed to clear pending run state: ' + (e.message || e)); + } + + return false; +} + function isPendingSolidify(state) { const lastRun = state && state.last_run ? state.last_run : null; const lastSolid = state && state.last_solidify ? state.last_solidify : null; @@ -35,6 +57,42 @@ function parseMs(v, fallback) { 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); + if (!Number.isFinite(pid) || pid <= 0) { + console.log('[Singleton] Corrupt lock file (invalid PID). Taking over.'); + } else { + try { + process.kill(pid, 0); + 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]; @@ -45,11 +103,17 @@ async function main() { 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 { getEvolutionDir } = require('./src/gep/paths'); + const solidifyStatePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json'); const minSleepMs = parseMs(process.env.EVOLVER_MIN_SLEEP_MS, 2000); const maxSleepMs = parseMs(process.env.EVOLVER_MAX_SLEEP_MS, 300000); @@ -65,10 +129,19 @@ async function main() { const maxRssMb = parseMs(process.env.EVOLVER_MAX_RSS_MB, 500) || 500; const suicideEnabled = String(process.env.EVOLVER_SUICIDE || '').toLowerCase() !== 'false'; - let currentSleepMs = Math.min(maxSleepMs, Math.max(minSleepMs, minSleepMs)); + // 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. @@ -83,6 +156,16 @@ async function main() { try { await evolve.run(); ok = true; + + if (String(process.env.EVOLVE_BRIDGE || '').toLowerCase() === 'false') { + const stAfterRun = readJsonSafe(solidifyStatePath); + if (isPendingSolidify(stAfterRun)) { + const cleared = rejectPendingRun(solidifyStatePath); + if (cleared) { + console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode (state only, no rollback).'); + } + } + } } catch (error) { const msg = error && error.message ? String(error.message) : String(error); console.error(`Evolution cycle failed: ${msg}`); @@ -101,19 +184,44 @@ async function main() { const memMb = process.memoryUsage().rss / 1024 / 1024; if (cycleCount >= maxCyclesPerProcess || memMb > maxRssMb) { console.log(`[Daemon] Restarting self (cycles=${cycleCount}, rssMb=${memMb.toFixed(0)})`); - const child = spawn(process.execPath, [__filename, ...args], { - detached: true, - stdio: 'ignore', - env: process.env, - }); - child.unref(); - process.exit(0); + try { + const spawnOpts = { + detached: true, + stdio: 'ignore', + env: process.env, + windowsHide: true, + }; + const child = spawn(process.execPath, [__filename, ...args], spawnOpts); + child.unref(); + releaseLock(); + process.exit(0); + } catch (spawnErr) { + console.error('[Daemon] Spawn failed, continuing current process:', spawnErr.message); + } } } + 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); + 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 @@ -151,21 +259,257 @@ async function main() { 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)); + } + } + + if (res && res.hubReviewPromise) { + await res.hubReviewPromise; + } 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 if (command === 'review' || command === '--review') { + const { getEvolutionDir, getRepoRoot } = require('./src/gep/paths'); + const { loadGenes } = require('./src/gep/assetStore'); + const { execSync } = require('child_process'); + + const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json'); + const state = readJsonSafe(statePath); + const lastRun = state && state.last_run ? state.last_run : null; + + if (!lastRun || !lastRun.run_id) { + console.log('[Review] No pending evolution run to review.'); + console.log('Run "node index.js run" first to produce changes, then review before solidifying.'); + process.exit(0); + } + + const lastSolid = state && state.last_solidify ? state.last_solidify : null; + if (lastSolid && String(lastSolid.run_id) === String(lastRun.run_id)) { + console.log('[Review] Last run has already been solidified. Nothing to review.'); + process.exit(0); + } + + const repoRoot = getRepoRoot(); + let diff = ''; + try { + const unstaged = execSync('git diff', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }).trim(); + const staged = execSync('git diff --cached', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }).trim(); + const untracked = execSync('git ls-files --others --exclude-standard', { cwd: repoRoot, encoding: 'utf8', timeout: 10000 }).trim(); + if (staged) diff += '=== Staged Changes ===\n' + staged + '\n\n'; + if (unstaged) diff += '=== Unstaged Changes ===\n' + unstaged + '\n\n'; + if (untracked) diff += '=== Untracked Files ===\n' + untracked + '\n'; + } catch (e) { + diff = '(failed to capture diff: ' + (e.message || e) + ')'; + } + + const genes = loadGenes(); + const geneId = lastRun.selected_gene_id ? String(lastRun.selected_gene_id) : null; + const gene = geneId ? genes.find(g => g && g.type === 'Gene' && g.id === geneId) : null; + const signals = Array.isArray(lastRun.signals) ? lastRun.signals : []; + const mutation = lastRun.mutation || null; + + console.log('\n' + '='.repeat(60)); + console.log('[Review] Pending evolution run: ' + lastRun.run_id); + console.log('='.repeat(60)); + console.log('\n--- Gene ---'); + if (gene) { + console.log(' ID: ' + gene.id); + console.log(' Category: ' + (gene.category || '?')); + console.log(' Summary: ' + (gene.summary || '?')); + if (Array.isArray(gene.strategy) && gene.strategy.length > 0) { + console.log(' Strategy:'); + gene.strategy.forEach((s, i) => console.log(' ' + (i + 1) + '. ' + s)); + } + } else { + console.log(' (no gene selected or gene not found: ' + (geneId || 'none') + ')'); + } + + console.log('\n--- Signals ---'); + if (signals.length > 0) { + signals.forEach(s => console.log(' - ' + s)); + } else { + console.log(' (no signals)'); + } + + console.log('\n--- Mutation ---'); + if (mutation) { + console.log(' Category: ' + (mutation.category || '?')); + console.log(' Risk Level: ' + (mutation.risk_level || '?')); + if (mutation.rationale) console.log(' Rationale: ' + mutation.rationale); + } else { + console.log(' (no mutation data)'); + } + + if (lastRun.blast_radius_estimate) { + console.log('\n--- Blast Radius Estimate ---'); + const br = lastRun.blast_radius_estimate; + console.log(' Files changed: ' + (br.files_changed || '?')); + console.log(' Lines changed: ' + (br.lines_changed || '?')); + } + + console.log('\n--- Diff ---'); + if (diff.trim()) { + console.log(diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, ' + diff.length + ' chars total)' : diff); + } else { + console.log(' (no changes detected)'); + } + console.log('='.repeat(60)); + + if (args.includes('--approve')) { + console.log('\n[Review] Approved. Running solidify...\n'); + try { + const res = solidify({ + intent: lastRun.intent || undefined, + rollbackOnFailure: true, + }); + 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.hubReviewPromise) { + await res.hubReviewPromise; + } + process.exit(res && res.ok ? 0 : 2); + } catch (error) { + console.error('[SOLIDIFY] Error:', error); + process.exit(2); + } + } else if (args.includes('--reject')) { + console.log('\n[Review] Rejected. Rolling back changes...'); + try { + execSync('git checkout -- .', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }); + execSync('git clean -fd', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }); + const evolDir = getEvolutionDir(); + const sp = path.join(evolDir, 'evolution_solidify_state.json'); + if (fs.existsSync(sp)) { + const s = readJsonSafe(sp); + if (s && s.last_run) { + s.last_solidify = { run_id: s.last_run.run_id, rejected: true, timestamp: new Date().toISOString() }; + fs.writeFileSync(sp, JSON.stringify(s, null, 2)); + } + } + console.log('[Review] Changes rolled back.'); + } catch (e) { + console.error('[Review] Rollback failed:', e.message || e); + process.exit(2); + } + } else { + console.log('\nTo approve and solidify: node index.js review --approve'); + console.log('To reject and rollback: node index.js review --reject'); + } + + } else if (command === 'asset-log') { + const { summarizeCallLog, readCallLog, getLogPath } = require('./src/gep/assetCallLog'); + + const runIdFlag = args.find(a => typeof a === 'string' && a.startsWith('--run=')); + const actionFlag = args.find(a => typeof a === 'string' && a.startsWith('--action=')); + const lastFlag = args.find(a => typeof a === 'string' && a.startsWith('--last=')); + const sinceFlag = args.find(a => typeof a === 'string' && a.startsWith('--since=')); + const jsonMode = args.includes('--json'); + + const opts = {}; + if (runIdFlag) opts.run_id = runIdFlag.slice('--run='.length); + if (actionFlag) opts.action = actionFlag.slice('--action='.length); + if (lastFlag) opts.last = parseInt(lastFlag.slice('--last='.length), 10); + if (sinceFlag) opts.since = sinceFlag.slice('--since='.length); + + if (jsonMode) { + const entries = readCallLog(opts); + console.log(JSON.stringify(entries, null, 2)); + } else { + const summary = summarizeCallLog(opts); + console.log(`\n[Asset Call Log] ${getLogPath()}`); + console.log(` Total entries: ${summary.total_entries}`); + console.log(` Unique assets: ${summary.unique_assets}`); + console.log(` Unique runs: ${summary.unique_runs}`); + console.log(` By action:`); + for (const [action, count] of Object.entries(summary.by_action)) { + console.log(` ${action}: ${count}`); + } + if (summary.entries.length > 0) { + console.log(`\n Recent entries:`); + const show = summary.entries.slice(-10); + for (const e of show) { + const ts = e.timestamp ? e.timestamp.slice(0, 19) : '?'; + const assetShort = e.asset_id ? e.asset_id.slice(0, 20) + '...' : '(none)'; + const sigPreview = Array.isArray(e.signals) ? e.signals.slice(0, 3).join(', ') : ''; + console.log(` [${ts}] ${e.action || '?'} asset=${assetShort} score=${e.score || '-'} mode=${e.mode || '-'} signals=[${sigPreview}] run=${e.run_id || '-'}`); + } + } else { + console.log('\n No entries found.'); + } + console.log(''); + } + } else { - console.log(`Usage: node index.js [run|/evolve|solidify] [--loop] + console.log(`Usage: node index.js [run|/evolve|solidify|review|distill|asset-log] [--loop] - solidify flags: - --dry-run - --no-rollback - --intent=repair|optimize|innovate - - --summary=...`); + - --summary=... + - review flags: + - --approve (approve and solidify the pending changes) + - --reject (reject and rollback the pending changes) + - distill flags: + - --response-file= (LLM response file for skill distillation) + - asset-log flags: + - --run= (filter by run ID) + - --action= (filter: hub_search_hit, hub_search_miss, asset_reuse, asset_reference, asset_publish, asset_publish_skip) + - --last= (show last N entries) + - --since= (entries after date) + - --json (raw JSON output)`); } } if (require.main === module) { main(); } + +module.exports = { + main, + readJsonSafe, + rejectPendingRun, + isPendingSolidify, +}; diff --git a/memory b/memory deleted file mode 120000 index 4290249..0000000 --- a/memory +++ /dev/null @@ -1 +0,0 @@ -../../memory \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 30f2561..0000000 --- a/package-lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "evolver", - "version": "1.7.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "evolver", - "version": "1.7.0", - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 5fd664f..1cd5c3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "evolver", - "version": "1.7.0", - "description": "A self-evolution engine for AI agents. Features automated log analysis and protocol-constrained evolution with auditable assets.", + "version": "1.28.0", + "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", @@ -18,9 +18,12 @@ "start": "node index.js", "run": "node index.js run", "solidify": "node index.js solidify", + "review": "node index.js review", "a2a:export": "node scripts/a2a_export.js", "a2a:ingest": "node scripts/a2a_ingest.js", "a2a:promote": "node scripts/a2a_promote.js" }, - "dependencies": {} + "dependencies": { + "dotenv": "^16.4.7" + } } diff --git a/public.manifest.json b/public.manifest.json deleted file mode 100644 index d8dfcb7..0000000 --- a/public.manifest.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": 1, - "outDir": "dist-public", - "include": [ - "assets/cover.png", - "assets/gep/genes.json", - "assets/gep/capsules.json", - "assets/gep/events.jsonl", - "index.js", - "package.json", - "README.md", - "README.zh-CN.md", - "SKILL.md", - "CONTRIBUTING.md", - "LICENSE", - "src/**", - "scripts/*.js", - ".gitignore" - ], - "exclude": [ - "assets/gep/candidates.jsonl", - "assets/gep/external_candidates.jsonl", - "docs/**", - "memory/**", - "dist-public/**", - "test/**", - "docker-compose.test.yml", - ".git/**" - ], - "rewrite": { - "README.md": { - "replace": [ - { - "from": "This repo is a private staging area for the public repository.", - "to": "This repository is the public distribution." - } - ] - }, - "README.zh-CN.md": { - "replace": [ - { - "from": "本仓库作为 public 仓库的私有维护区。", - "to": "本仓库为公开发行版本。" - } - ] - } - } -} - diff --git a/scripts/a2a_export.js b/scripts/a2a_export.js index 194f6fd..a89dead 100644 --- a/scripts/a2a_export.js +++ b/scripts/a2a_export.js @@ -1,6 +1,7 @@ const { loadGenes, loadCapsules, readAllEvents } = require('../src/gep/assetStore'); -const { exportEligibleCapsules } = require('../src/gep/a2a'); +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); @@ -8,13 +9,30 @@ function main() { 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(); - var eligible = exportEligibleCapsules({ capsules: capsules, events: events }); + + // 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 genes = loadGenes(); var hello = buildHello({ geneCount: genes.length, capsuleCount: capsules.length }); process.stdout.write(JSON.stringify(hello) + '\n'); if (persist) { try { getTransport().send(hello); } catch (e) {} } diff --git a/scripts/a2a_ingest.js b/scripts/a2a_ingest.js index 6541607..c74409c 100644 --- a/scripts/a2a_ingest.js +++ b/scripts/a2a_ingest.js @@ -1,7 +1,7 @@ var fs = require('fs'); var assetStore = require('../src/gep/assetStore'); var a2a = require('../src/gep/a2a'); -var memGraph = require('../src/gep/memoryGraph'); +var memGraph = require('../src/gep/memoryGraphAdapter'); var contentHash = require('../src/gep/contentHash'); var a2aProto = require('../src/gep/a2aProtocol'); diff --git a/scripts/a2a_promote.js b/scripts/a2a_promote.js index 2a56529..d1d376d 100644 --- a/scripts/a2a_promote.js +++ b/scripts/a2a_promote.js @@ -29,11 +29,11 @@ function main() { 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 --id --validated'); + 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' : ''; - if (!type) throw new Error('Invalid --type. Use capsule or gene.'); + 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; @@ -62,6 +62,18 @@ function main() { 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) { diff --git a/scripts/build_public.js b/scripts/build_public.js index e237487..b3acef4 100644 --- a/scripts/build_public.js +++ b/scripts/build_public.js @@ -136,6 +136,7 @@ function rewritePackageJson(outDirAbs) { start: 'node index.js', run: 'node index.js run', solidify: 'node index.js solidify', + review: 'node index.js review', 'a2a:export': 'node scripts/a2a_export.js', 'a2a:ingest': 'node scripts/a2a_ingest.js', 'a2a:promote': 'node scripts/a2a_promote.js', diff --git a/scripts/export_history.js b/scripts/export_history.js deleted file mode 100644 index e5bdfb9..0000000 --- a/scripts/export_history.js +++ /dev/null @@ -1,98 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const REPO_ROOT = path.resolve(__dirname, '..'); -try { - require('dotenv').config({ path: path.join(REPO_ROOT, '.env') }); -} catch (e) { - // dotenv may be missing; proceed gracefully -} - -const DOC_TOKEN = 'NwV1dKCLyoPdIvx3biRcKS1Jnwg'; // The new doc -const LOG_FILE = path.join(REPO_ROOT, 'memory', 'mad_dog_evolution.log'); -const TOKEN_FILE = path.join(REPO_ROOT, 'memory', 'feishu_token.json'); - -async function exportEvolutionHistory() { - let token; - try { token = JSON.parse(fs.readFileSync(TOKEN_FILE)).token; } catch(e) {} - if (!token) return console.error("No token"); - - let logContent = ''; - try { logContent = fs.readFileSync(LOG_FILE, 'utf8'); } catch(e) { return console.error("No log file"); } - - // Parse Log - const cycles = []; - const regex = /Evolution Cycle #(\d+)([\s\S]*?)(?:Cycle End|System:)/g; - let match; - while ((match = regex.exec(logContent)) !== null) { - let details = match[2].trim(); - // Clean up details - details = details.replace(/\[.*?\]/g, '').replace(/\n+/g, '\n').trim(); - if (details.length > 500) details = details.substring(0, 500) + '...'; - - cycles.push({ - id: match[1], - content: details - }); - } - - if (cycles.length === 0) { - // Fallback: Just dump the last 50 lines - cycles.push({ id: "Unknown", content: logContent.split('\n').slice(-50).join('\n') }); - } - - // Reverse to show latest first - cycles.reverse(); - - // Format for Feishu Doc (Markdown) - let markdown = "# Evolution History (Loop)\n\n> Auto-generated report of self-improvement cycles.\n\n"; - - // Split into chunks if too big - const chunks = []; - let currentChunk = markdown; - - for (const cycle of cycles) { - const entry = `### Cycle #${cycle.id}\n${cycle.content}\n\n---\n\n`; - if (currentChunk.length + entry.length > 8000) { // Safety limit - chunks.push(currentChunk); - currentChunk = entry; - } else { - currentChunk += entry; - } - } - chunks.push(currentChunk); - - // Append Chunks - console.log(`Exporting ${chunks.length} chunks...`); - - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - console.log(`Uploading Chunk ${i+1}/${chunks.length}...`); - - // Fallback: Send as Code Block to avoid parsing errors - const blocks = [{ - block_type: 14, // Code - code: { - style: { language: 1 }, // Plain Text - elements: [{ text_run: { content: chunk, text_element_style: {} } }] - } - }]; - - const res = await fetch(`https://open.feishu.cn/open-apis/docx/v1/documents/${DOC_TOKEN}/blocks/${DOC_TOKEN}/children`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json; charset=utf-8' - }, - body: JSON.stringify({ children: blocks }) - }); - - const data = await res.json(); - if (data.code !== 0) console.error(`Chunk ${i+1} failed:`, JSON.stringify(data)); - else console.log(`Chunk ${i+1} success.`); - - await new Promise(r => setTimeout(r, 500)); - } -} - -exportEvolutionHistory(); - diff --git a/scripts/publish_public.js b/scripts/publish_public.js index 57cee7e..4ebc109 100644 --- a/scripts/publish_public.js +++ b/scripts/publish_public.js @@ -348,6 +348,51 @@ async function ensureReleaseWithApi({ repo, tag, title, notes, notesFile, dryRun 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'; @@ -461,12 +506,15 @@ function main() { if (!dryRun && !pending) { process.stdout.write('Public repo already matches build output. Skipping commit/push.\n'); } else { - // Avoid relying on global git config (CI environments often lack user.name/user.email). + 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 "${msg.replace( - /"/g, - '\\"' - )}"`, + `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 }); diff --git a/scripts/validate-modules.js b/scripts/validate-modules.js new file mode 100644 index 0000000..752a5ca --- /dev/null +++ b/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/src/canary.js b/src/canary.js new file mode 100644 index 0000000..389a0e5 --- /dev/null +++ b/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/src/evolve.js b/src/evolve.js index 7ff7683..9604bc5 100644 --- a/src/evolve.js +++ b/src/evolve.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { execSync } = require('child_process'); -const { getRepoRoot, getMemoryDir } = require('./gep/paths'); +const { getRepoRoot, getMemoryDir, getSessionScope } = require('./gep/paths'); const { extractSignals } = require('./gep/signals'); const { loadGenes, @@ -12,22 +12,33 @@ const { appendCandidateJsonl, readRecentCandidates, readRecentExternalCandidates, + readRecentFailedCapsules, + ensureAssetFiles, } = require('./gep/assetStore'); const { selectGeneAndCapsule, matchPatternToSignals } = require('./gep/selector'); -const { buildGepPrompt } = require('./gep/prompt'); +const { buildGepPrompt, buildReusePrompt, buildHubMatchedBlock } = require('./gep/prompt'); +const { hubSearch } = require('./gep/hubSearch'); +const { logAssetCall } = require('./gep/assetCallLog'); const { extractCapabilityCandidates, renderCandidatesPreview } = require('./gep/candidates'); +const memoryAdapter = require('./gep/memoryGraphAdapter'); const { - getMemoryAdvice, + getAdvice: getMemoryAdvice, recordSignalSnapshot, recordHypothesis, recordAttempt, - recordOutcomeFromState, + recordOutcome: recordOutcomeFromState, memoryGraphPath, -} = require('./gep/memoryGraph'); +} = memoryAdapter; const { readStateForSolidify, writeStateForSolidify } = require('./gep/solidify'); +const { fetchTasks, selectBestTask, claimTask, taskToSignals, claimWorkerTask, estimateCommitmentDeadline } = 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 { shouldReflect, buildReflectionContext, recordReflection } = require('./gep/reflection'); +const { loadNarrativeSummary } = require('./gep/narrativeMemory'); +const { maybeReportIssue } = require('./gep/issueReporter'); const REPO_ROOT = getRepoRoot(); @@ -91,9 +102,17 @@ function formatSessionLog(jsonlContent) { 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')) continue; + if (content.includes('NO_REPLY') && !data.message.errorMessage) continue; // Clean up newlines for compact reading content = content.replace(/\n+/g, ' ').slice(0, 300); @@ -143,46 +162,72 @@ function readRealSessionLog() { try { if (!fs.existsSync(AGENT_SESSIONS_DIR)) return '[NO SESSION LOGS FOUND]'; - let files = []; + 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 - // Strategy: Node.js native sort (Faster than execSync for <100 files) - // Note: performMaintenance() ensures file count stays low (~100 max) - files = fs + // 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')) + .filter(f => f.endsWith('.jsonl') && !f.includes('.lock')) .map(f => { try { - return { name: f, time: fs.statSync(path.join(AGENT_SESSIONS_DIR, f)).mtime.getTime() }; + 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(Boolean) + .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]'; - let content = ''; - const TARGET_BYTES = 100000; // Increased context (was 64000) for smarter evolution - - // Read the latest file first (efficient tail read) - const latestFile = path.join(AGENT_SESSIONS_DIR, files[0].name); - content = readRecentLog(latestFile, TARGET_BYTES); - - // If content is short (e.g. just started a session), peek at the previous one too - if (content.length < TARGET_BYTES && files.length > 1) { - const prevFile = path.join(AGENT_SESSIONS_DIR, files[1].name); - const needed = TARGET_BYTES - content.length; - const prevContent = readRecentLog(prevFile, needed); + // 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).`); + } + } - // Format to show continuity - content = `\n--- PREVIOUS SESSION (${files[1].name}) ---\n${formatSessionLog( - prevContent - )}\n\n--- CURRENT SESSION (${files[0].name}) ---\n${formatSessionLog(content)}`; - } else { - content = formatSessionLog(content); + 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}]`; @@ -230,29 +275,37 @@ function checkSystemHealth() { } catch (e) {} try { - // Process count: Attempt pgrep first (faster), fallback to ps - try { - const pgrep = execSync('pgrep -c node', { + if (process.platform === 'win32') { + const wmic = execSync('tasklist /FI "IMAGENAME eq node.exe" /NH', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], - timeout: 2000, + timeout: 3000, + windowsHide: true, }); - report.push(`Node Processes: ${pgrep.trim()}`); - } catch (e) { - // Fallback to ps if pgrep fails/missing - 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()}`); + 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 = []; - if (!process.env.GEMINI_API_KEY) issues.push('Gemini Key Missing'); // Generic Integration Status Check (Decoupled) if (process.env.INTEGRATION_STATUS_CMD) { @@ -261,6 +314,7 @@ function checkSystemHealth() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, + windowsHide: true, }); if (status.trim()) issues.push(status.trim()); } catch (e) {} @@ -291,17 +345,75 @@ function getMutationDirective(logContent) { `; } -const STATE_FILE = path.join(MEMORY_DIR, 'evolution_state.json'); -// Fix: Look for MEMORY.md in root first, then memory dir to support both layouts -const ROOT_MEMORY = path.join(REPO_ROOT, 'MEMORY.md'); +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 : DIR_MEMORY; -const USER_FILE = path.join(REPO_ROOT, 'USER.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 { - if (!fs.existsSync(MEMORY_FILE)) return '[MEMORY.md MISSING]'; - const content = fs.readFileSync(MEMORY_FILE, 'utf8'); + // 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]` @@ -339,20 +451,38 @@ function getNextCycleId() { } function performMaintenance() { + // Auto-update check (rate-limited, non-fatal). + checkAndAutoUpdate(); + try { if (!fs.existsSync(AGENT_SESSIONS_DIR)) return; - // Count files const files = fs.readdirSync(AGENT_SESSIONS_DIR).filter(f => f.endsWith('.jsonl')); - if (files.length < 100) return; // Limit before cleanup - console.log(`[Maintenance] Found ${files.length} session logs. Archiving old ones...`); + // 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 }); - // Sort by time (oldest first) 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() }; @@ -363,7 +493,6 @@ function performMaintenance() { .filter(Boolean) .sort((a, b) => a.time - b.time); - // Keep last 50 files, archive the rest const toArchive = fileStats.slice(0, fileStats.length - 50); for (const file of toArchive) { @@ -371,22 +500,202 @@ function performMaintenance() { const newPath = path.join(ARCHIVE_DIR, file.name); fs.renameSync(oldPath, newPath); } - console.log(`[Maintenance] Archived ${toArchive.length} logs to ${ARCHIVE_DIR}`); + 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) { @@ -397,7 +706,14 @@ async function run() { if (lastRun && lastRun.run_id) { const pending = !lastSolid || !lastSolid.run_id || String(lastSolid.run_id) !== String(lastRun.run_id); if (pending) { - // Backoff to avoid tight loops and disk churn. + 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; @@ -410,11 +726,89 @@ async function run() { } } + // 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; + + // SAFEGUARD: Git repository check. + // Solidify, rollback, and blast radius all depend on git. Without a git repo + // these operations silently produce empty results, leading to data loss. + try { + execSync('git rev-parse --git-dir', { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }); + } catch (_) { + console.error('[Evolver] FATAL: Not a git repository (' + REPO_ROOT + ').'); + console.error('[Evolver] Evolver requires git for rollback, blast radius calculation, and solidify.'); + console.error('[Evolver] Run "git init && git add -A && git commit -m init" in your project root, then try again.'); + process.exitCode = 1; + return; + } + + 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 - performMaintenance(); + 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); @@ -569,8 +963,159 @@ async function run() { 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}`); + } + + // --- Auto GitHub Issue Reporter --- + // When persistent failures are detected, file an issue to the upstream repo + // with sanitized logs and environment info. + try { + await maybeReportIssue({ + signals, + recentEvents, + sessionLog: recentMasterLog, + }); + } catch (e) { + console.log(`[IssueReporter] Check 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'; + let claimed = alreadyClaimed; + if (!alreadyClaimed) { + const commitDeadline = estimateCommitmentDeadline(best); + claimed = await claimTask(best.id || best.task_id, commitDeadline ? { commitment_deadline: commitDeadline } : undefined); + if (claimed && commitDeadline) { + best._commitment_deadline = commitDeadline; + console.log(`[Commitment] Deadline set: ${commitDeadline}`); + } + } + 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}`); + } + + // --- Commitment: check for overdue tasks from heartbeat --- + // If Hub reported overdue tasks, prioritize resuming them by injecting their + // signals at the front. This does not change activeTask selection (the overdue + // task should already be claimed/active from a previous cycle). + try { + const { consumeOverdueTasks } = require('./gep/a2aProtocol'); + const overdueTasks = consumeOverdueTasks(); + if (overdueTasks.length > 0) { + for (const ot of overdueTasks) { + const otId = ot.task_id || ot.id; + if (activeTask && (activeTask.id === otId || activeTask.task_id === otId)) { + console.warn(`[Commitment] Active task "${activeTask.title || otId}" is OVERDUE -- prioritizing completion.`); + signals.unshift('overdue_task', 'urgent'); + break; + } + } + } + } catch {} + + // --- Worker Pool: select task from heartbeat available_work (deferred claim) --- + // Only remember the best task and inject its signals; actual claim+complete + // happens atomically in solidify.js after a successful evolution cycle. + if (!activeTask && process.env.WORKER_ENABLED === '1') { + try { + const { consumeAvailableWork } = require('./gep/a2aProtocol'); + const workerTasks = consumeAvailableWork(); + if (workerTasks.length > 0) { + let taskMemoryEvents = []; + try { + const { tryReadMemoryGraphEvents } = require('./gep/memoryGraph'); + taskMemoryEvents = tryReadMemoryGraphEvents(1000); + } catch {} + const best = selectBestTask(workerTasks, taskMemoryEvents); + if (best) { + activeTask = best; + activeTask._worker_pending = true; + const taskSignals = taskToSignals(best); + for (const sig of taskSignals) { + if (!signals.includes(sig)) signals.unshift(sig); + } + console.log(`[WorkerPool] Selected worker task (deferred claim): "${best.title || best.id}" (${taskSignals.length} signals injected)`); + } + } + } catch (e) { + console.log(`[WorkerPool] Task selection failed (non-fatal): ${e.message}`); + } + } + const recentErrorMatches = recentMasterLog.match(/\[ERROR|Error:|Exception:|FAIL|Failed|"isError":true/gi) || []; const recentErrorCount = recentErrorMatches.length; @@ -580,8 +1125,10 @@ async function run() { 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, @@ -596,6 +1143,10 @@ async function run() { 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 }); @@ -603,7 +1154,7 @@ async function run() { // 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()}`); - process.exit(2); + 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. @@ -612,7 +1163,7 @@ async function run() { } catch (e) { console.error(`[MemoryGraph] Signal snapshot write failed: ${e.message}`); console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); - process.exit(2); + throw new Error(`MemoryGraph Signal snapshot write failed: ${e.message}`); } // Capability candidates (structured, short): persist and preview. @@ -688,6 +1239,20 @@ async function run() { } } 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 { @@ -695,7 +1260,39 @@ async function run() { } catch (e) { console.error(`[MemoryGraph] Read failed: ${e.message}`); console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); - process.exit(2); + throw new Error(`MemoryGraph Read failed: ${e.message}`); + } + + // Reflection Phase: periodically pause to assess evolution strategy. + try { + const cycleState = fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) : {}; + const cycleCount = cycleState.cycleCount || 0; + if (shouldReflect({ cycleCount, recentEvents })) { + const narrativeSummary = loadNarrativeSummary(3000); + const reflectionCtx = buildReflectionContext({ + recentEvents, + signals, + memoryAdvice, + narrative: narrativeSummary, + }); + recordReflection({ + cycle_count: cycleCount, + signals_snapshot: signals.slice(0, 20), + preferred_gene: memoryAdvice && memoryAdvice.preferredGeneId ? memoryAdvice.preferredGeneId : null, + banned_genes: memoryAdvice && Array.isArray(memoryAdvice.bannedGeneIds) ? memoryAdvice.bannedGeneIds : [], + context_preview: reflectionCtx.slice(0, 1000), + }); + console.log(`[Reflection] Strategic reflection recorded at cycle ${cycleCount}.`); + } + } catch (e) { + console.log('[Reflection] Failed (non-fatal): ' + (e && e.message ? e.message : e)); + } + + var recentFailedCapsules = []; + try { + recentFailedCapsules = readRecentFailedCapsules(50); + } catch (e) { + console.log('[FailedCapsules] Read failed (non-fatal): ' + e.message); } const { selectedGene, capsuleCandidates, selector } = selectGeneAndCapsule({ @@ -704,6 +1301,7 @@ async function run() { signals, memoryAdvice, driftEnabled: IS_RANDOM_DRIFT, + failedCapsules: recentFailedCapsules, }); const selectedBy = memoryAdvice && memoryAdvice.preferredGeneId ? 'memory_graph+selector' : 'selector'; @@ -782,7 +1380,7 @@ async function run() { } catch (e) { console.error(`[MemoryGraph] Hypothesis write failed: ${e.message}`); console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); - process.exit(2); + 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. @@ -802,7 +1400,7 @@ async function run() { } catch (e) { console.error(`[MemoryGraph] Attempt write failed: ${e.message}`); console.error(`[MemoryGraph] Refusing to evolve without causal memory. Target: ${memoryGraphPath()}`); - process.exit(2); + throw new Error(`MemoryGraph Attempt write failed: ${e.message}`); } // Solidify state: capture minimal, auditable context for post-patch validation + asset write. @@ -821,6 +1419,7 @@ async function run() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, + windowsHide: true, }); baselineUntracked = String(out) .split('\n') @@ -834,6 +1433,7 @@ async function run() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, + windowsHide: true, }); baselineHead = String(out || '').trim() || null; } catch (e) {} @@ -868,11 +1468,41 @@ async function run() { : [], 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, + worker_assignment_id: activeTask ? (activeTask._worker_assignment_id || null) : null, + worker_pending: activeTask ? (activeTask._worker_pending || false) : false, + commitment_deadline: activeTask ? (activeTask._commitment_deadline || null) : null, + applied_lessons: hubLessons.map(function(l) { return l.lesson_id; }).filter(Boolean), + hub_lessons: hubLessons, }; writeStateForSolidify(prevState); + + if (hubHit && hubHit.hit) { + const assetAction = hubHit.mode === 'direct' ? 'asset_reuse' : 'asset_reference'; + logAssetCall({ + run_id: runId, + action: assetAction, + asset_id: hubHit.asset_id || null, + asset_type: hubHit.match && hubHit.match.type ? hubHit.match.type : null, + source_node_id: hubHit.source_node_id || null, + chain_id: hubHit.chain_id || null, + score: hubHit.score || null, + mode: hubHit.mode, + signals: Array.isArray(signals) ? signals : [], + extra: { + selected_gene_id: selectedGene && selectedGene.id ? selectedGene.id : null, + task_id: activeTask ? (activeTask.id || activeTask.task_id || null) : null, + }, + }); + } } catch (e) { console.error(`[SolidifyState] Write failed: ${e.message}`); } @@ -884,6 +1514,20 @@ async function run() { ? '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} @@ -898,6 +1542,23 @@ Notes: - ${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} @@ -925,19 +1586,35 @@ Mutation directive: ${mutationDirective} `.trim(); - const prompt = buildGepPrompt({ - nowIso: new Date().toISOString(), - context, - signals, - selector, - parentEventId: getLastEventId(), - selectedGene, - capsuleCandidates, - genesPreview, - capsulesPreview, - capabilityCandidatesPreview, - externalCandidatesPreview, - }); + // 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'; @@ -951,6 +1628,8 @@ ${mutationDirective} `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]`); } @@ -969,7 +1648,7 @@ ${mutationDirective} let artifact = null; try { artifact = writePromptArtifact({ - memoryDir: MEMORY_DIR, + memoryDir: getEvolutionDir(), cycleId, runId, prompt, @@ -996,7 +1675,7 @@ ${mutationDirective} '', '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: sleep 3 && node index.js --loop", agentId: "main", cleanup: "delete", label: "gep_loop_next" })', + '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), diff --git a/src/gep/a2a.js b/src/gep/a2a.js index 45f01cd..f47a7cf 100644 --- a/src/gep/a2a.js +++ b/src/gep/a2a.js @@ -28,8 +28,8 @@ function getBlastRadiusLimits() { 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; + var files = blastRadius && Number.isFinite(Number(blastRadius.files)) ? Math.max(0, Number(blastRadius.files)) : 0; + var lines = blastRadius && Number.isFinite(Number(blastRadius.lines)) ? Math.max(0, Number(blastRadius.lines)) : 0; return files <= lim.maxFiles && lines <= lim.maxLines; } @@ -112,6 +112,26 @@ function exportEligibleCapsules(params) { 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 []; @@ -148,5 +168,6 @@ function readTextIfExists(filePath) { module.exports = { isAllowedA2AAsset, lowerConfidence, isBlastRadiusSafe, computeCapsuleSuccessStreak, isCapsuleBroadcastEligible, - exportEligibleCapsules, parseA2AInput, readTextIfExists, + exportEligibleCapsules, isGeneBroadcastEligible, + exportEligibleGenes, parseA2AInput, readTextIfExists, }; diff --git a/src/gep/a2aProtocol.js b/src/gep/a2aProtocol.js index d7f8948..f62355f 100644 --- a/src/gep/a2aProtocol.js +++ b/src/gep/a2aProtocol.js @@ -18,27 +18,91 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { getGepAssetsDir } = require('./paths'); +const { getGepAssetsDir, getEvolverLogPath } = 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 (process.env.A2A_NODE_ID) return String(process.env.A2A_NODE_ID); - const raw = process.cwd() + '|' + (process.env.AGENT_NAME || 'default'); - return 'node_' + crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12); + 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; + } + + console.warn('[a2aProtocol] A2A_NODE_ID is not set. Computing node ID from device fingerprint. ' + + 'This ID may change across machines or environments. ' + + 'Set A2A_NODE_ID after registering at https://evomap.ai to use a stable identity.'); + + 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) { + if (!params || typeof params !== 'object') { + throw new Error('buildMessage requires a params object'); + } var messageType = params.messageType; var payload = params.payload; var senderId = params.senderId; @@ -78,28 +142,86 @@ function buildPublish(opts) { 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: asset.asset_id || computeAssetId(asset), + 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 || {}; + var fetchPayload = { + asset_type: o.assetType || null, + local_id: o.localId || null, + content_hash: o.contentHash || null, + }; + if (Array.isArray(o.signals) && o.signals.length > 0) { + fetchPayload.signals = o.signals; + } + if (o.searchOnly === true) { + fetchPayload.search_only = true; + } + if (Array.isArray(o.assetIds) && o.assetIds.length > 0) { + fetchPayload.asset_ids = o.assetIds; + } return buildMessage({ messageType: 'fetch', senderId: o.nodeId, - payload: { - asset_type: o.assetType || null, - local_id: o.localId || null, - content_hash: o.contentHash || null, - }, + payload: fetchPayload, }); } @@ -224,6 +346,328 @@ function fileTransportList(opts) { 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); + return fetch(endpoint, { + method: 'POST', + headers: buildHubHeaders(), + 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 signals = (opts && Array.isArray(opts.signals)) ? opts.signals : null; + var fetchMsg = buildFetch({ assetType: assetType, signals: signals }); + var endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/fetch'; + return fetch(endpoint, { + method: 'POST', + headers: buildHubHeaders(), + 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; +var _latestAvailableWork = []; +var _latestOverdueTasks = []; +var _pendingCommitmentUpdates = []; +var _cachedHubNodeSecret = null; +var _heartbeatIntervalMs = 0; +var _heartbeatRunning = false; + +var NODE_SECRET_FILE = path.join(NODE_ID_DIR, 'node_secret'); + +function _loadPersistedNodeSecret() { + try { + if (fs.existsSync(NODE_SECRET_FILE)) { + var s = fs.readFileSync(NODE_SECRET_FILE, 'utf8').trim(); + if (s && /^[a-f0-9]{64}$/i.test(s)) return s; + } + } catch {} + return null; +} + +function _persistNodeSecret(secret) { + try { + if (!fs.existsSync(NODE_ID_DIR)) { + fs.mkdirSync(NODE_ID_DIR, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(NODE_SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 }); + } catch {} +} + +function getHubUrl() { + return process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || ''; +} + +function buildHubHeaders() { + var headers = { 'Content-Type': 'application/json' }; + var secret = getHubNodeSecret(); + if (secret) headers['Authorization'] = 'Bearer ' + secret; + return headers; +} + +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: buildHubHeaders(), + body: JSON.stringify(msg), + signal: AbortSignal.timeout(15000), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + var secret = (data && data.payload && data.payload.node_secret) + || (data && data.node_secret) + || null; + if (secret && /^[a-f0-9]{64}$/i.test(secret)) { + _cachedHubNodeSecret = secret; + _persistNodeSecret(secret); + } + return { ok: true, response: data }; + }) + .catch(function (err) { return { ok: false, error: err.message }; }); +} + +function getHubNodeSecret() { + if (_cachedHubNodeSecret) return _cachedHubNodeSecret; + var persisted = _loadPersistedNodeSecret(); + if (persisted) { + _cachedHubNodeSecret = persisted; + return persisted; + } + return null; +} + +function _scheduleNextHeartbeat(delayMs) { + if (!_heartbeatRunning) return; + if (_heartbeatTimer) clearTimeout(_heartbeatTimer); + var delay = delayMs || _heartbeatIntervalMs; + _heartbeatTimer = setTimeout(function () { + if (!_heartbeatRunning) return; + sendHeartbeat().catch(function () {}); + _scheduleNextHeartbeat(); + }, delay); + if (_heartbeatTimer.unref) _heartbeatTimer.unref(); +} + +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 bodyObj = { + node_id: nodeId, + sender_id: nodeId, + version: PROTOCOL_VERSION, + uptime_ms: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0, + timestamp: new Date().toISOString(), + }; + + var meta = {}; + + if (process.env.WORKER_ENABLED === '1') { + var domains = (process.env.WORKER_DOMAINS || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean); + meta.worker_enabled = true; + meta.worker_domains = domains; + meta.max_load = Math.max(1, Number(process.env.WORKER_MAX_LOAD) || 5); + } + + if (_pendingCommitmentUpdates.length > 0) { + meta.commitment_updates = _pendingCommitmentUpdates.splice(0); + } + + if (Object.keys(meta).length > 0) { + bodyObj.meta = meta; + } + + var body = JSON.stringify(bodyObj); + + _heartbeatTotalSent++; + + return fetch(endpoint, { + method: 'POST', + headers: buildHubHeaders(), + body: body, + signal: AbortSignal.timeout(10000), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data && (data.error === 'rate_limited' || data.status === 'rate_limited')) { + var retryMs = Number(data.retry_after_ms) || 0; + var policy = data.policy || {}; + var windowMs = Number(policy.window_ms) || 0; + var backoff = retryMs > 0 ? retryMs + 5000 : (windowMs > 0 ? windowMs + 5000 : _heartbeatIntervalMs); + if (backoff > _heartbeatIntervalMs) { + console.warn('[Heartbeat] Rate limited by hub. Next attempt in ' + Math.round(backoff / 1000) + 's. ' + + 'Consider increasing HEARTBEAT_INTERVAL_MS to >= ' + (windowMs || backoff) + 'ms.'); + _scheduleNextHeartbeat(backoff); + } + return { ok: false, error: 'rate_limited', retryMs: backoff }; + } + 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 }; + }); + } + if (Array.isArray(data.available_work)) { + _latestAvailableWork = data.available_work; + } + if (Array.isArray(data.overdue_tasks) && data.overdue_tasks.length > 0) { + _latestOverdueTasks = data.overdue_tasks; + console.warn('[Commitment] ' + data.overdue_tasks.length + ' overdue task(s) detected via heartbeat.'); + } + _heartbeatConsecutiveFailures = 0; + try { + var logPath = getEvolverLogPath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + var now = new Date(); + try { + fs.utimesSync(logPath, now, now); + } catch (e) { + if (e && e.code === 'ENOENT') { + try { + var fd = fs.openSync(logPath, 'a'); + fs.closeSync(fd); + fs.utimesSync(logPath, now, now); + } catch (innerErr) { + console.warn('[Heartbeat] Failed to create evolver_loop.log: ' + innerErr.message); + } + } else { + console.warn('[Heartbeat] Failed to touch evolver_loop.log: ' + e.message); + } + } + } catch (outerErr) { + console.warn('[Heartbeat] Failed to ensure evolver_loop.log: ' + outerErr.message); + } + 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 getLatestAvailableWork() { + return _latestAvailableWork; +} + +function consumeAvailableWork() { + var work = _latestAvailableWork; + _latestAvailableWork = []; + return work; +} + +function getOverdueTasks() { + return _latestOverdueTasks; +} + +function consumeOverdueTasks() { + var tasks = _latestOverdueTasks; + _latestOverdueTasks = []; + return tasks; +} + +/** + * Queue a commitment deadline update to be sent with the next heartbeat. + * @param {string} taskId + * @param {string} deadlineIso - ISO-8601 deadline + * @param {boolean} [isAssignment] - true if this is a WorkAssignment + */ +function queueCommitmentUpdate(taskId, deadlineIso, isAssignment) { + if (!taskId || !deadlineIso) return; + _pendingCommitmentUpdates.push({ + task_id: taskId, + deadline: deadlineIso, + assignment: !!isAssignment, + }); +} + +function startHeartbeat(intervalMs) { + if (_heartbeatRunning) return; + _heartbeatIntervalMs = intervalMs || Number(process.env.HEARTBEAT_INTERVAL_MS) || 360000; // default 6min + _heartbeatStartedAt = Date.now(); + _heartbeatRunning = true; + + 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 () {}).then(function () { + if (!_heartbeatRunning) return; + // First heartbeat after hello completes, with enough gap to avoid rate limit + _scheduleNextHeartbeat(Math.max(30000, _heartbeatIntervalMs)); + }); +} + +function stopHeartbeat() { + _heartbeatRunning = false; + if (_heartbeatTimer) { + clearTimeout(_heartbeatTimer); + _heartbeatTimer = null; + } +} + +function getHeartbeatStats() { + return { + running: _heartbeatRunning, + uptimeMs: _heartbeatStartedAt ? Date.now() - _heartbeatStartedAt : 0, + totalSent: _heartbeatTotalSent, + totalFailed: _heartbeatTotalFailed, + consecutiveFailures: _heartbeatConsecutiveFailures, + }; +} + // --- Transport registry --- var transports = { @@ -232,6 +676,11 @@ var transports = { receive: fileTransportReceive, list: fileTransportList, }, + http: { + send: httpTransportSend, + receive: httpTransportReceive, + list: httpTransportList, + }, }; function getTransport(name) { @@ -257,6 +706,7 @@ module.exports = { buildMessage, buildHello, buildPublish, + buildPublishBundle, buildFetch, buildReport, buildDecision, @@ -268,4 +718,19 @@ module.exports = { fileTransportSend, fileTransportReceive, fileTransportList, + httpTransportSend, + httpTransportReceive, + httpTransportList, + sendHeartbeat, + sendHelloToHub, + startHeartbeat, + stopHeartbeat, + getHeartbeatStats, + getLatestAvailableWork, + consumeAvailableWork, + getOverdueTasks, + consumeOverdueTasks, + queueCommitmentUpdate, + getHubNodeSecret, + buildHubHeaders, }; diff --git a/src/gep/analyzer.js b/src/gep/analyzer.js new file mode 100644 index 0000000..7fd3f7a --- /dev/null +++ b/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/src/gep/assetCallLog.js b/src/gep/assetCallLog.js new file mode 100644 index 0000000..b28c97a --- /dev/null +++ b/src/gep/assetCallLog.js @@ -0,0 +1,130 @@ +// Append-only asset call log for tracking Hub asset interactions per evolution run. +// Log file: {evolution_dir}/asset_call_log.jsonl + +const fs = require('fs'); +const path = require('path'); +const { getEvolutionDir } = require('./paths'); + +function getLogPath() { + return path.join(getEvolutionDir(), 'asset_call_log.jsonl'); +} + +function ensureDir(filePath) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Append a single asset call record to the log. + * + * @param {object} entry + * @param {string} entry.run_id + * @param {string} entry.action - hub_search_hit | hub_search_miss | asset_reuse | asset_reference | asset_publish | asset_publish_skip + * @param {string} [entry.asset_id] + * @param {string} [entry.asset_type] + * @param {string} [entry.source_node_id] + * @param {string} [entry.chain_id] + * @param {number} [entry.score] + * @param {string} [entry.mode] - direct | reference + * @param {string[]} [entry.signals] + * @param {string} [entry.reason] + * @param {object} [entry.extra] + */ +function logAssetCall(entry) { + if (!entry || typeof entry !== 'object') return; + try { + const logPath = getLogPath(); + ensureDir(logPath); + const record = { + timestamp: new Date().toISOString(), + ...entry, + }; + fs.appendFileSync(logPath, JSON.stringify(record) + '\n', 'utf8'); + } catch (e) { + // Non-fatal: never block evolution for logging failure + } +} + +/** + * Read asset call log entries with optional filters. + * + * @param {object} [opts] + * @param {string} [opts.run_id] - filter by run_id + * @param {string} [opts.action] - filter by action type + * @param {number} [opts.last] - only return last N entries + * @param {string} [opts.since] - ISO date string, only entries after this time + * @returns {object[]} + */ +function readCallLog(opts) { + const o = opts || {}; + const logPath = getLogPath(); + if (!fs.existsSync(logPath)) return []; + + const raw = fs.readFileSync(logPath, 'utf8'); + const lines = raw.split('\n').filter(Boolean); + + let entries = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line)); + } catch (e) { /* skip corrupt lines */ } + } + + if (o.since) { + const sinceTs = new Date(o.since).getTime(); + if (Number.isFinite(sinceTs)) { + entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceTs); + } + } + + if (o.run_id) { + entries = entries.filter(e => e.run_id === o.run_id); + } + + if (o.action) { + entries = entries.filter(e => e.action === o.action); + } + + if (o.last && Number.isFinite(o.last) && o.last > 0) { + entries = entries.slice(-o.last); + } + + return entries; +} + +/** + * Summarize asset call log (for CLI display). + * + * @param {object} [opts] - same filters as readCallLog + * @returns {object} summary with totals and per-action counts + */ +function summarizeCallLog(opts) { + const entries = readCallLog(opts); + const actionCounts = {}; + const assetsSeen = new Set(); + const runsSeen = new Set(); + + for (const e of entries) { + const a = e.action || 'unknown'; + actionCounts[a] = (actionCounts[a] || 0) + 1; + if (e.asset_id) assetsSeen.add(e.asset_id); + if (e.run_id) runsSeen.add(e.run_id); + } + + return { + total_entries: entries.length, + unique_assets: assetsSeen.size, + unique_runs: runsSeen.size, + by_action: actionCounts, + entries, + }; +} + +module.exports = { + logAssetCall, + readCallLog, + summarizeCallLog, + getLogPath, +}; diff --git a/src/gep/assetStore.js b/src/gep/assetStore.js index 96cab0d..144aba9 100644 --- a/src/gep/assetStore.js +++ b/src/gep/assetStore.js @@ -26,6 +26,14 @@ function writeJsonAtomic(filePath, obj) { 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, @@ -44,8 +52,8 @@ function getDefaultGenes() { ], constraints: { max_files: 12, forbidden_paths: ['.git', 'node_modules'] }, validation: [ - 'node -e "require(\'./src/evolve\'); require(\'./src/gep/solidify\'); console.log(\'ok\')"', - 'node -e "require(\'./src/gep/selector\'); require(\'./src/gep/memoryGraph\'); console.log(\'ok\')"', + buildValidationCmd(['src/evolve', 'src/gep/solidify']), + buildValidationCmd(['src/gep/selector', 'src/gep/memoryGraph']), ], }, { @@ -61,7 +69,7 @@ function getDefaultGenes() { 'Solidify: record EvolutionEvent, update Gene definitions, create Capsule on success', ], constraints: { max_files: 20, forbidden_paths: ['.git', 'node_modules'] }, - validation: ['node -e "require(\'./src/evolve\'); require(\'./src/gep/prompt\'); console.log(\'ok\')"'], + validation: [buildValidationCmd(['src/evolve', 'src/gep/prompt'])], }, ], }; @@ -70,12 +78,62 @@ function getDefaultGenes() { 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) {} -function loadGenes() { return readJsonIfExists(genesPath(), getDefaultGenes()).genes || []; } -function loadCapsules() { return readJsonIfExists(capsulesPath(), getDefaultCapsules()).capsules || []; } + // 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 { @@ -174,10 +232,66 @@ function upsertCapsule(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, - genesPath, capsulesPath, eventsPath, candidatesPath, externalCandidatesPath, + appendFailedCapsule, readRecentFailedCapsules, + genesPath, capsulesPath, eventsPath, candidatesPath, externalCandidatesPath, failedCapsulesPath, + ensureAssetFiles, buildValidationCmd, }; diff --git a/src/gep/assets.js b/src/gep/assets.js new file mode 100644 index 0000000..333ce4c --- /dev/null +++ b/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/src/gep/bridge.js b/src/gep/bridge.js index 5321222..92a4db9 100644 --- a/src/gep/bridge.js +++ b/src/gep/bridge.js @@ -57,11 +57,10 @@ function renderSessionsSpawnCall({ task, agentId, label, cleanup }) { const l = String(label || 'gep_bridge'); const c = cleanup ? String(cleanup) : 'delete'; - // Render in the same style as existing recovery snippet in index.js. - // Use JSON.stringify for task string to ensure escaping is valid. - return `sessions_spawn({\n task: ${JSON.stringify(t)},\n agentId: ${JSON.stringify(a)},\n cleanup: ${JSON.stringify( - c - )},\n label: ${JSON.stringify(l)}\n})`; + // 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 = { diff --git a/src/gep/candidates.js b/src/gep/candidates.js index c630911..d7a56f7 100644 --- a/src/gep/candidates.js +++ b/src/gep/candidates.js @@ -95,7 +95,7 @@ function extractCapabilityCandidates({ recentSessionTranscript, signals }) { ]; for (const sc of signalCandidates) { - if (!signalList.includes(sc.signal)) continue; + 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({ diff --git a/src/gep/contentHash.js b/src/gep/contentHash.js index 6939687..58c3f60 100644 --- a/src/gep/contentHash.js +++ b/src/gep/contentHash.js @@ -6,7 +6,7 @@ const crypto = require('crypto'); // Schema version for all GEP asset types. // Bump MINOR for additive fields; MAJOR for breaking changes. -const SCHEMA_VERSION = '1.5.0'; +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. diff --git a/src/gep/deviceId.js b/src/gep/deviceId.js new file mode 100644 index 0000000..3f67038 --- /dev/null +++ b/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/src/gep/envFingerprint.js b/src/gep/envFingerprint.js index f553d55..1ac2523 100644 --- a/src/gep/envFingerprint.js +++ b/src/gep/envFingerprint.js @@ -7,26 +7,36 @@ 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, - cwd: process.cwd(), - captured_at: new Date().toISOString(), + client: pkgName || 'evolver', + client_version: pkgVersion, + region: region, + cwd: crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12), + container: isContainer(), }; } @@ -35,10 +45,13 @@ function captureEnvFingerprint() { function envFingerprintKey(fp) { if (!fp || typeof fp !== 'object') return 'unknown'; const parts = [ + fp.device_id || '', fp.node_version || '', fp.platform || '', fp.arch || '', - fp.evolver_version || '', + 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); } diff --git a/src/gep/hubReview.js b/src/gep/hubReview.js new file mode 100644 index 0000000..1f4d1ea --- /dev/null +++ b/src/gep/hubReview.js @@ -0,0 +1,206 @@ +// Hub Asset Review: submit usage-verified reviews after solidify. +// +// When an evolution cycle reuses a Hub asset (source_type = 'reused' or 'reference'), +// we submit a review to POST /a2a/assets/:assetId/reviews after solidify completes. +// Rating is derived from outcome: success -> 4-5, failure -> 1-2. +// Reviews are non-blocking; errors never affect the solidify result. +// Duplicate prevention: a local file tracks reviewed assetIds to avoid re-reviewing. + +const fs = require('fs'); +const path = require('path'); +const { getNodeId, getHubNodeSecret } = require('./a2aProtocol'); +const { logAssetCall } = require('./assetCallLog'); + +const REVIEW_HISTORY_FILE = path.join( + require('./paths').getEvolutionDir(), + 'hub_review_history.json' +); + +const REVIEW_HISTORY_MAX_ENTRIES = 500; + +function _loadReviewHistory() { + try { + if (!fs.existsSync(REVIEW_HISTORY_FILE)) return {}; + const raw = fs.readFileSync(REVIEW_HISTORY_FILE, 'utf8'); + if (!raw.trim()) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +function _saveReviewHistory(history) { + try { + const dir = path.dirname(REVIEW_HISTORY_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const keys = Object.keys(history); + if (keys.length > REVIEW_HISTORY_MAX_ENTRIES) { + const sorted = keys + .map(k => ({ k, t: history[k].at || 0 })) + .sort((a, b) => a.t - b.t); + const toRemove = sorted.slice(0, keys.length - REVIEW_HISTORY_MAX_ENTRIES); + for (const entry of toRemove) delete history[entry.k]; + } + const tmp = REVIEW_HISTORY_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(history, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, REVIEW_HISTORY_FILE); + } catch {} +} + +function _alreadyReviewed(assetId) { + const history = _loadReviewHistory(); + return !!history[assetId]; +} + +function _markReviewed(assetId, rating, success) { + const history = _loadReviewHistory(); + history[assetId] = { at: Date.now(), rating, success }; + _saveReviewHistory(history); +} + +function _deriveRating(outcome, constraintCheck) { + if (outcome && outcome.status === 'success') { + const score = Number(outcome.score) || 0; + return score >= 0.85 ? 5 : 4; + } + const hasConstraintViolation = + constraintCheck && + Array.isArray(constraintCheck.violations) && + constraintCheck.violations.length > 0; + return hasConstraintViolation ? 1 : 2; +} + +function _buildReviewContent({ outcome, gene, signals, blast, sourceType }) { + const parts = []; + const status = outcome && outcome.status ? outcome.status : 'unknown'; + const score = outcome && Number.isFinite(Number(outcome.score)) + ? Number(outcome.score).toFixed(2) : '?'; + + parts.push('Outcome: ' + status + ' (score: ' + score + ')'); + parts.push('Reuse mode: ' + (sourceType || 'unknown')); + + if (gene && gene.id) { + parts.push('Gene: ' + gene.id + ' (' + (gene.category || 'unknown') + ')'); + } + + if (Array.isArray(signals) && signals.length > 0) { + parts.push('Signals: ' + signals.slice(0, 6).join(', ')); + } + + if (blast) { + parts.push('Blast radius: ' + (blast.files || 0) + ' file(s), ' + (blast.lines || 0) + ' line(s)'); + } + + if (status === 'success') { + parts.push('The fetched asset was successfully applied and solidified.'); + } else { + parts.push('The fetched asset did not lead to a successful evolution cycle.'); + } + + return parts.join('\n').slice(0, 2000); +} + +function getHubUrl() { + return (process.env.A2A_HUB_URL || '').replace(/\/+$/, ''); +} + +async function submitHubReview({ + reusedAssetId, + sourceType, + outcome, + gene, + signals, + blast, + constraintCheck, + runId, +}) { + var hubUrl = getHubUrl(); + if (!hubUrl) return { submitted: false, reason: 'no_hub_url' }; + + if (!reusedAssetId || typeof reusedAssetId !== 'string') { + return { submitted: false, reason: 'no_reused_asset_id' }; + } + + if (sourceType !== 'reused' && sourceType !== 'reference') { + return { submitted: false, reason: 'not_hub_sourced' }; + } + + if (_alreadyReviewed(reusedAssetId)) { + return { submitted: false, reason: 'already_reviewed' }; + } + + var rating = _deriveRating(outcome, constraintCheck); + var content = _buildReviewContent({ outcome, gene, signals, blast, sourceType }); + var senderId = getNodeId(); + + var endpoint = hubUrl + '/a2a/assets/' + encodeURIComponent(reusedAssetId) + '/reviews'; + + var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + var secret = getHubNodeSecret(); + if (secret) { + headers['Authorization'] = 'Bearer ' + secret; + } + + var body = JSON.stringify({ + sender_id: senderId, + rating: rating, + content: content, + }); + + try { + var controller = new AbortController(); + var timer = setTimeout(function () { controller.abort('hub_review_timeout'); }, 10000); + + var res = await fetch(endpoint, { + method: 'POST', + headers: headers, + body: body, + signal: controller.signal, + }); + clearTimeout(timer); + + if (res.ok) { + _markReviewed(reusedAssetId, rating, true); + console.log( + '[HubReview] Submitted review for ' + reusedAssetId + ': rating=' + rating + ', outcome=' + (outcome && outcome.status) + ); + logAssetCall({ + run_id: runId || null, + action: 'hub_review_submitted', + asset_id: reusedAssetId, + extra: { rating: rating, outcome_status: outcome && outcome.status }, + }); + return { submitted: true, rating: rating, asset_id: reusedAssetId }; + } + + var errData = await res.json().catch(function () { return {}; }); + var errCode = errData.error || errData.code || ('http_' + res.status); + + if (errCode === 'already_reviewed') { + _markReviewed(reusedAssetId, rating, false); + } + + console.log('[HubReview] Hub rejected review for ' + reusedAssetId + ': ' + errCode); + logAssetCall({ + run_id: runId || null, + action: 'hub_review_rejected', + asset_id: reusedAssetId, + extra: { rating: rating, error: errCode }, + }); + return { submitted: false, reason: errCode, rating: rating }; + } catch (err) { + var reason = err.name === 'AbortError' ? 'timeout' : 'fetch_error'; + console.log('[HubReview] Failed (non-fatal, ' + reason + '): ' + err.message); + logAssetCall({ + run_id: runId || null, + action: 'hub_review_failed', + asset_id: reusedAssetId, + extra: { rating: rating, reason: reason, error: err.message }, + }); + return { submitted: false, reason: reason, error: err.message }; + } +} + +module.exports = { + submitHubReview, +}; diff --git a/src/gep/hubSearch.js b/src/gep/hubSearch.js new file mode 100644 index 0000000..d6870fa --- /dev/null +++ b/src/gep/hubSearch.js @@ -0,0 +1,237 @@ +// 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) +// +// Two-phase search-then-fetch to minimize credit cost: +// Phase 1: POST /a2a/fetch with signals + search_only=true (free, metadata only) +// Phase 2: POST /a2a/fetch with asset_ids=[selected] (pays for 1 asset only) + +const { getNodeId, buildFetch, getHubNodeSecret } = require('./a2aProtocol'); +const { logAssetCall } = require('./assetCallLog'); + +const DEFAULT_MIN_REUSE_SCORE = 0.72; +const DEFAULT_REUSE_MODE = 'reference'; // 'direct' | 'reference' +const MAX_STREAK_CAP = 5; +const TIMEOUT_REASON = 'hub_search_timeout'; + +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 * min(max(success_streak, 1), MAX_STREAK_CAP) * (reputation / 100) + * Streak is capped to prevent unbounded score inflation. + */ +function scoreHubResult(asset) { + const confidence = Number(asset.confidence) || 0; + const streak = Math.min(Math.max(Number(asset.success_streak) || 0, 1), MAX_STREAK_CAP); + const repRaw = Number(asset.reputation_score); + const reputation = Number.isFinite(repRaw) ? repRaw : 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) { + 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 assets matching the given signals. + * + * Two-phase flow to minimize credit cost: + * Phase 1: search_only=true -> get candidate metadata (free, no credit cost) + * Phase 2: asset_ids=[best_match] -> fetch full payload for the selected asset only + * + * Falls back to single-call fetch (old behavior) if search_only is not supported. + * 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.map(s => typeof s === 'string' ? s.trim() : '').filter(Boolean) + : []; + if (signalList.length === 0) return { hit: false, reason: 'no_signals' }; + + const threshold = (opts && Number.isFinite(opts.threshold)) ? opts.threshold : getMinReuseScore(); + const timeout = (opts && Number.isFinite(opts.timeoutMs)) ? opts.timeoutMs : 8000; + + try { + // Phase 1: search_only to get candidate metadata (free) + const searchMsg = buildFetch({ signals: signalList, searchOnly: true }); + const endpoint = hubUrl + '/a2a/fetch'; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(TIMEOUT_REASON), timeout); + + const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + const secret = getHubNodeSecret(); + if (secret) { + headers['Authorization'] = 'Bearer ' + secret; + } else { + const token = process.env.A2A_HUB_TOKEN; + if (token) headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(searchMsg), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) { + logAssetCall({ + run_id: (opts && opts.run_id) || null, + action: 'hub_search_miss', + signals: signalList, + reason: `hub_http_${res.status}`, + via: 'search_then_fetch', + }); + return { hit: false, reason: `hub_http_${res.status}` }; + } + + const data = await res.json(); + const results = (data && data.payload && Array.isArray(data.payload.results)) + ? data.payload.results + : []; + + if (results.length === 0) { + logAssetCall({ + run_id: (opts && opts.run_id) || null, + action: 'hub_search_miss', + signals: signalList, + reason: 'no_results', + via: 'search_then_fetch', + }); + return { hit: false, reason: 'no_results' }; + } + + const pick = pickBestMatch(results, threshold); + if (!pick) { + logAssetCall({ + run_id: (opts && opts.run_id) || null, + action: 'hub_search_miss', + signals: signalList, + reason: 'below_threshold', + extra: { candidates: results.length, threshold }, + via: 'search_then_fetch', + }); + return { hit: false, reason: 'below_threshold', candidates: results.length }; + } + + // Phase 2: fetch full payload for the selected asset only (pays for 1 asset) + const selectedAssetId = pick.match.asset_id; + if (selectedAssetId) { + try { + const fetchMsg = buildFetch({ assetIds: [selectedAssetId] }); + const controller2 = new AbortController(); + const timer2 = setTimeout(() => controller2.abort(TIMEOUT_REASON), timeout); + + const res2 = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(fetchMsg), + signal: controller2.signal, + }); + clearTimeout(timer2); + + if (res2.ok) { + const data2 = await res2.json(); + const fullResults = (data2 && data2.payload && Array.isArray(data2.payload.results)) + ? data2.payload.results + : []; + if (fullResults.length > 0) { + pick.match = { ...pick.match, ...fullResults[0] }; + } + } + } catch (fetchErr) { + console.log(`[HubSearch] Phase 2 fetch failed (non-fatal): ${fetchErr.message}`); + } + } + + console.log(`[HubSearch] Hit via search+fetch: ${pick.match.asset_id || 'unknown'} (score=${pick.score}, mode=${pick.mode})`); + + logAssetCall({ + run_id: (opts && opts.run_id) || null, + action: 'hub_search_hit', + asset_id: pick.match.asset_id || null, + asset_type: pick.match.asset_type || pick.match.type || null, + source_node_id: pick.match.source_node_id || null, + chain_id: pick.match.chain_id || null, + score: pick.score, + mode: pick.mode, + signals: signalList, + via: 'search_then_fetch', + }); + + 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) { + const isTimeout = err.name === 'AbortError' || (err.cause && err.cause === TIMEOUT_REASON); + const reason = isTimeout ? 'timeout' : 'fetch_error'; + console.log(`[HubSearch] Failed (non-fatal, ${reason}): ${err.message}`); + logAssetCall({ + run_id: (opts && opts.run_id) || null, + action: 'hub_search_miss', + signals: signalList, + reason, + extra: { error: err.message }, + via: 'search_then_fetch', + }); + return { hit: false, reason, error: err.message }; + } +} + +module.exports = { + hubSearch, + scoreHubResult, + pickBestMatch, + getReuseMode, + getMinReuseScore, + getHubUrl, +}; diff --git a/src/gep/issueReporter.js b/src/gep/issueReporter.js new file mode 100644 index 0000000..2f29a25 --- /dev/null +++ b/src/gep/issueReporter.js @@ -0,0 +1,262 @@ +// Automatic GitHub issue reporter for recurring evolver failures. +// When the evolver hits persistent errors (failure streaks, recurring errors), +// this module files a GitHub issue with sanitized logs and environment info. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getEvolutionDir } = require('./paths'); +const { captureEnvFingerprint } = require('./envFingerprint'); +const { redactString } = require('./sanitize'); +const { getNodeId } = require('./a2aProtocol'); + +const STATE_FILE_NAME = 'issue_reporter_state.json'; +const DEFAULT_REPO = 'autogame-17/capability-evolver'; +const DEFAULT_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MIN_STREAK = 5; +const MAX_LOG_CHARS = 2000; +const MAX_EVENTS = 5; + +function getConfig() { + var enabled = String(process.env.EVOLVER_AUTO_ISSUE || 'true').toLowerCase(); + if (enabled === 'false' || enabled === '0') return null; + return { + repo: process.env.EVOLVER_ISSUE_REPO || DEFAULT_REPO, + cooldownMs: Number(process.env.EVOLVER_ISSUE_COOLDOWN_MS) || DEFAULT_COOLDOWN_MS, + minStreak: Number(process.env.EVOLVER_ISSUE_MIN_STREAK) || DEFAULT_MIN_STREAK, + }; +} + +function getGithubToken() { + return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT || ''; +} + +function getStatePath() { + return path.join(getEvolutionDir(), STATE_FILE_NAME); +} + +function readState() { + try { + var p = getStatePath(); + if (fs.existsSync(p)) { + return JSON.parse(fs.readFileSync(p, 'utf8')); + } + } catch (_) {} + return { lastReportedAt: null, recentIssueKeys: [] }; +} + +function writeState(state) { + try { + var dir = getEvolutionDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(getStatePath(), JSON.stringify(state, null, 2) + '\n'); + } catch (_) {} +} + +function truncateNodeId(nodeId) { + if (!nodeId || typeof nodeId !== 'string') return 'unknown'; + if (nodeId.length <= 10) return nodeId; + return nodeId.slice(0, 10) + '...'; +} + +function computeErrorKey(signals) { + var relevant = signals + .filter(function (s) { + return s.startsWith('recurring_errsig') || + s.startsWith('ban_gene:') || + s === 'recurring_error' || + s === 'failure_loop_detected' || + s === 'high_failure_ratio'; + }) + .sort() + .join('|'); + return crypto.createHash('sha256').update(relevant || 'unknown').digest('hex').slice(0, 16); +} + +function extractErrorSignature(signals) { + var errSig = signals.find(function (s) { return s.startsWith('recurring_errsig'); }); + if (errSig) { + return errSig.replace(/^recurring_errsig\(\d+x\):/, '').trim().slice(0, 200); + } + var banned = signals.find(function (s) { return s.startsWith('ban_gene:'); }); + if (banned) return 'Repeated failures with gene: ' + banned.replace('ban_gene:', ''); + return 'Persistent evolution failure'; +} + +function extractStreakCount(signals) { + for (var i = 0; i < signals.length; i++) { + if (signals[i].startsWith('consecutive_failure_streak_')) { + var n = parseInt(signals[i].replace('consecutive_failure_streak_', ''), 10); + if (Number.isFinite(n)) return n; + } + } + return 0; +} + +function formatRecentEvents(events) { + if (!Array.isArray(events) || events.length === 0) return '_No recent events available._'; + var failed = events.filter(function (e) { return e && e.outcome && e.outcome.status === 'failed'; }); + var rows = failed.slice(-MAX_EVENTS).map(function (e, idx) { + var intent = e.intent || '-'; + var gene = (Array.isArray(e.genes_used) && e.genes_used[0]) || '-'; + var outcome = (e.outcome && e.outcome.status) || '-'; + var reason = (e.outcome && e.outcome.reason) || ''; + if (reason.length > 80) reason = reason.slice(0, 80) + '...'; + reason = redactString(reason); + return '| ' + (idx + 1) + ' | ' + intent + ' | ' + gene + ' | ' + outcome + ' | ' + reason + ' |'; + }); + if (rows.length === 0) return '_No failed events in recent history._'; + return '| # | Intent | Gene | Outcome | Reason |\n|---|--------|------|---------|--------|\n' + rows.join('\n'); +} + +function buildIssueBody(opts) { + var fp = opts.envFingerprint || captureEnvFingerprint(); + var signals = opts.signals || []; + var recentEvents = opts.recentEvents || []; + var sessionLog = opts.sessionLog || ''; + var streakCount = extractStreakCount(signals); + var errorSig = extractErrorSignature(signals); + var nodeId = truncateNodeId(getNodeId()); + + var failureSignals = signals.filter(function (s) { + return s.startsWith('recurring_') || + s.startsWith('consecutive_failure') || + s.startsWith('failure_loop') || + s.startsWith('high_failure') || + s.startsWith('ban_gene:') || + s === 'force_innovation_after_repair_loop'; + }).join(', '); + + var sanitizedLog = redactString( + typeof sessionLog === 'string' ? sessionLog.slice(-MAX_LOG_CHARS) : '' + ); + + var eventsTable = formatRecentEvents(recentEvents); + + var reportId = crypto.createHash('sha256') + .update(nodeId + '|' + Date.now() + '|' + errorSig) + .digest('hex').slice(0, 12); + + var body = [ + '## Environment', + '- **Evolver Version:** ' + (fp.evolver_version || 'unknown'), + '- **Node.js:** ' + (fp.node_version || process.version), + '- **Platform:** ' + (fp.platform || process.platform) + ' ' + (fp.arch || process.arch), + '- **Container:** ' + (fp.container ? 'yes' : 'no'), + '', + '## Failure Summary', + '- **Consecutive failures:** ' + (streakCount || 'N/A'), + '- **Failure signals:** ' + (failureSignals || 'none'), + '', + '## Error Signature', + '```', + redactString(errorSig), + '```', + '', + '## Recent Evolution Events (sanitized)', + eventsTable, + '', + '## Session Log Excerpt (sanitized)', + '```', + sanitizedLog || '_No session log available._', + '```', + '', + '---', + '_This issue was automatically created by evolver v' + (fp.evolver_version || 'unknown') + '._', + '_Device: ' + nodeId + ' | Report ID: ' + reportId + '_', + ]; + + return body.join('\n'); +} + +function shouldReport(signals, config) { + if (!config) return false; + + var hasFailureLoop = signals.includes('failure_loop_detected'); + var hasRecurringAndHigh = signals.includes('recurring_error') && signals.includes('high_failure_ratio'); + + if (!hasFailureLoop && !hasRecurringAndHigh) return false; + + var streakCount = extractStreakCount(signals); + if (streakCount > 0 && streakCount < config.minStreak) return false; + + var state = readState(); + var errorKey = computeErrorKey(signals); + + if (state.lastReportedAt) { + var elapsed = Date.now() - new Date(state.lastReportedAt).getTime(); + if (elapsed < config.cooldownMs) { + var recentKeys = Array.isArray(state.recentIssueKeys) ? state.recentIssueKeys : []; + if (recentKeys.includes(errorKey)) { + return false; + } + } + } + + return true; +} + +async function createGithubIssue(repo, title, body, token) { + var url = 'https://api.github.com/repos/' + repo + '/issues'; + var response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ title: title, body: body }), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + var errText = ''; + try { errText = await response.text(); } catch (_) {} + throw new Error('GitHub API ' + response.status + ': ' + errText.slice(0, 200)); + } + + var data = await response.json(); + return { number: data.number, url: data.html_url }; +} + +async function maybeReportIssue(opts) { + var config = getConfig(); + if (!config) return; + + var signals = opts.signals || []; + + if (!shouldReport(signals, config)) return; + + var token = getGithubToken(); + if (!token) { + console.log('[IssueReporter] No GitHub token available. Skipping auto-report.'); + return; + } + + var errorSig = extractErrorSignature(signals); + var titleSig = errorSig.slice(0, 80); + var title = '[Auto] Recurring failure: ' + titleSig; + var body = buildIssueBody(opts); + + try { + var result = await createGithubIssue(config.repo, title, body, token); + console.log('[IssueReporter] Created GitHub issue #' + result.number + ': ' + result.url); + + var state = readState(); + var errorKey = computeErrorKey(signals); + var recentKeys = Array.isArray(state.recentIssueKeys) ? state.recentIssueKeys : []; + recentKeys.push(errorKey); + if (recentKeys.length > 20) recentKeys = recentKeys.slice(-20); + writeState({ + lastReportedAt: new Date().toISOString(), + recentIssueKeys: recentKeys, + lastIssueUrl: result.url, + lastIssueNumber: result.number, + }); + } catch (e) { + console.log('[IssueReporter] Failed to create issue (non-fatal): ' + (e && e.message ? e.message : String(e))); + } +} + +module.exports = { maybeReportIssue, buildIssueBody, shouldReport }; diff --git a/src/gep/llmReview.js b/src/gep/llmReview.js new file mode 100644 index 0000000..7f33f9b --- /dev/null +++ b/src/gep/llmReview.js @@ -0,0 +1,92 @@ +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { getRepoRoot } = require('./paths'); + +const REVIEW_ENABLED_KEY = 'EVOLVER_LLM_REVIEW'; +const REVIEW_TIMEOUT_MS = 30000; + +function isLlmReviewEnabled() { + return String(process.env[REVIEW_ENABLED_KEY] || '').toLowerCase() === 'true'; +} + +function buildReviewPrompt({ diff, gene, signals, mutation }) { + const geneId = gene && gene.id ? gene.id : '(unknown)'; + const category = (mutation && mutation.category) || (gene && gene.category) || 'unknown'; + const rationale = mutation && mutation.rationale ? String(mutation.rationale).slice(0, 500) : '(none)'; + const signalsList = Array.isArray(signals) ? signals.slice(0, 8).join(', ') : '(none)'; + const diffPreview = String(diff || '').slice(0, 6000); + + return `You are reviewing a code change produced by an autonomous evolution engine. + +## Context +- Gene: ${geneId} (${category}) +- Signals: [${signalsList}] +- Rationale: ${rationale} + +## Diff +\`\`\`diff +${diffPreview} +\`\`\` + +## Review Criteria +1. Does this change address the stated signals? +2. Are there any obvious regressions or bugs introduced? +3. Is the blast radius proportionate to the problem? +4. Are there any security or safety concerns? + +## Response Format +Respond with a JSON object: +{ + "approved": true|false, + "confidence": 0.0-1.0, + "concerns": ["..."], + "summary": "one-line review summary" +}`; +} + +function runLlmReview({ diff, gene, signals, mutation }) { + if (!isLlmReviewEnabled()) return null; + + const prompt = buildReviewPrompt({ diff, gene, signals, mutation }); + + try { + const repoRoot = getRepoRoot(); + + // Write prompt to a temp file to avoid shell quoting issues entirely. + const tmpFile = path.join(os.tmpdir(), 'evolver_review_prompt_' + process.pid + '.txt'); + fs.writeFileSync(tmpFile, prompt, 'utf8'); + + try { + // Use execFileSync to bypass shell interpretation (no quoting issues). + const reviewScript = ` + const fs = require('fs'); + const prompt = fs.readFileSync(process.argv[1], 'utf8'); + console.log(JSON.stringify({ approved: true, confidence: 0.7, concerns: [], summary: 'auto-approved (no external LLM configured)' })); + `; + const result = execFileSync(process.execPath, ['-e', reviewScript, tmpFile], { + cwd: repoRoot, + encoding: 'utf8', + timeout: REVIEW_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + try { + return JSON.parse(result.trim()); + } catch (_) { + return { approved: true, confidence: 0.5, concerns: ['failed to parse review response'], summary: 'review parse error' }; + } + } finally { + try { fs.unlinkSync(tmpFile); } catch (_) {} + } + } catch (e) { + console.log('[LLMReview] Execution failed (non-fatal): ' + (e && e.message ? e.message : e)); + return { approved: true, confidence: 0.5, concerns: ['review execution failed'], summary: 'review timeout or error' }; + } +} + +module.exports = { isLlmReviewEnabled, runLlmReview, buildReviewPrompt }; diff --git a/src/gep/memoryGraph.js b/src/gep/memoryGraph.js index d6ee30e..e22d9d9 100644 --- a/src/gep/memoryGraph.js +++ b/src/gep/memoryGraph.js @@ -77,13 +77,14 @@ function extractErrorSignatureFromSignals(signals) { } function memoryGraphPath() { - const memoryDir = getMemoryDir(); - return process.env.MEMORY_GRAPH_PATH || path.join(memoryDir, 'memory_graph.jsonl'); + const { getEvolutionDir } = require('./paths'); + const evoDir = getEvolutionDir(); + return process.env.MEMORY_GRAPH_PATH || path.join(evoDir, 'memory_graph.jsonl'); } function memoryGraphStatePath() { - const memoryDir = getMemoryDir(); - return path.join(memoryDir, 'memory_graph_state.json'); + const { getEvolutionDir } = require('./paths'); + return path.join(getEvolutionDir(), 'memory_graph_state.json'); } function appendJsonl(filePath, obj) { diff --git a/src/gep/memoryGraphAdapter.js b/src/gep/memoryGraphAdapter.js new file mode 100644 index 0000000..15b79d3 --- /dev/null +++ b/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/src/gep/mutation.js b/src/gep/mutation.js index f919bcc..a7c6bed 100644 --- a/src/gep/mutation.js +++ b/src/gep/mutation.js @@ -23,6 +23,7 @@ function uniqStrings(list) { 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; @@ -36,12 +37,17 @@ var OPPORTUNITY_SIGNALS = [ '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++) { - if (list.includes(OPPORTUNITY_SIGNALS[i])) return true; + var name = OPPORTUNITY_SIGNALS[i]; + if (list.includes(name)) return true; + if (list.some(function (s) { return s.startsWith(name + ':'); })) return true; } return false; } @@ -51,6 +57,12 @@ function mutationCategoryFromContext({ signals, driftEnabled }) { 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'; } diff --git a/src/gep/narrativeMemory.js b/src/gep/narrativeMemory.js new file mode 100644 index 0000000..fae8854 --- /dev/null +++ b/src/gep/narrativeMemory.js @@ -0,0 +1,108 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getNarrativePath, getEvolutionDir } = require('./paths'); + +const MAX_NARRATIVE_ENTRIES = 30; +const MAX_NARRATIVE_SIZE = 12000; + +function ensureDir(dir) { + try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch (_) {} +} + +function recordNarrative({ gene, signals, mutation, outcome, blast, capsule }) { + const narrativePath = getNarrativePath(); + ensureDir(path.dirname(narrativePath)); + + const ts = new Date().toISOString().replace('T', ' ').slice(0, 19); + const geneId = gene && gene.id ? gene.id : '(auto)'; + const category = (mutation && mutation.category) || (gene && gene.category) || 'unknown'; + const status = outcome && outcome.status ? outcome.status : 'unknown'; + const score = outcome && typeof outcome.score === 'number' ? outcome.score.toFixed(2) : '?'; + const signalsSummary = Array.isArray(signals) ? signals.slice(0, 4).join(', ') : '(none)'; + const filesChanged = blast ? blast.files : 0; + const linesChanged = blast ? blast.lines : 0; + const rationale = mutation && mutation.rationale + ? String(mutation.rationale).slice(0, 200) : ''; + const strategy = gene && Array.isArray(gene.strategy) + ? gene.strategy.slice(0, 3).map((s, i) => ` ${i + 1}. ${s}`).join('\n') : ''; + const capsuleSummary = capsule && capsule.summary ? String(capsule.summary).slice(0, 200) : ''; + + const entry = [ + `### [${ts}] ${category.toUpperCase()} - ${status}`, + `- Gene: ${geneId} | Score: ${score} | Scope: ${filesChanged} files, ${linesChanged} lines`, + `- Signals: [${signalsSummary}]`, + rationale ? `- Why: ${rationale}` : null, + strategy ? `- Strategy:\n${strategy}` : null, + capsuleSummary ? `- Result: ${capsuleSummary}` : null, + '', + ].filter(line => line !== null).join('\n'); + + let existing = ''; + try { + if (fs.existsSync(narrativePath)) { + existing = fs.readFileSync(narrativePath, 'utf8'); + } + } catch (_) {} + + if (!existing.trim()) { + existing = '# Evolution Narrative\n\nA chronological record of evolution decisions and outcomes.\n\n'; + } + + const combined = existing + entry; + const trimmed = trimNarrative(combined); + + const tmp = narrativePath + '.tmp'; + fs.writeFileSync(tmp, trimmed, 'utf8'); + fs.renameSync(tmp, narrativePath); +} + +function trimNarrative(content) { + if (content.length <= MAX_NARRATIVE_SIZE) return content; + + const headerEnd = content.indexOf('###'); + if (headerEnd < 0) return content.slice(-MAX_NARRATIVE_SIZE); + + const header = content.slice(0, headerEnd); + const entries = content.slice(headerEnd).split(/(?=^### \[)/m); + + while (entries.length > MAX_NARRATIVE_ENTRIES) { + entries.shift(); + } + + let result = header + entries.join(''); + if (result.length > MAX_NARRATIVE_SIZE) { + const keep = Math.max(1, entries.length - 5); + result = header + entries.slice(-keep).join(''); + } + + return result; +} + +function loadNarrativeSummary(maxChars) { + const limit = Number.isFinite(maxChars) ? maxChars : 4000; + const narrativePath = getNarrativePath(); + try { + if (!fs.existsSync(narrativePath)) return ''; + const content = fs.readFileSync(narrativePath, 'utf8'); + if (!content.trim()) return ''; + + const headerEnd = content.indexOf('###'); + if (headerEnd < 0) return ''; + + const entries = content.slice(headerEnd).split(/(?=^### \[)/m); + const recent = entries.slice(-8); + let summary = recent.join(''); + if (summary.length > limit) { + summary = summary.slice(-limit); + const firstEntry = summary.indexOf('### ['); + if (firstEntry > 0) summary = summary.slice(firstEntry); + } + return summary.trim(); + } catch (_) { + return ''; + } +} + +module.exports = { recordNarrative, loadNarrativeSummary, trimNarrative }; diff --git a/src/gep/paths.js b/src/gep/paths.js index 588781c..129582c 100644 --- a/src/gep/paths.js +++ b/src/gep/paths.js @@ -1,23 +1,113 @@ const path = require('path'); +const fs = require('fs'); function getRepoRoot() { - // src/gep/paths.js -> repo root + if (process.env.EVOLVER_REPO_ROOT) { + return process.env.EVOLVER_REPO_ROOT; + } + + let dir = path.resolve(__dirname, '..', '..'); + while (dir !== '/' && dir !== '.') { + const gitDir = path.join(dir, '.git'); + if (fs.existsSync(gitDir)) { + return dir; + } + dir = path.dirname(dir); + } + return path.resolve(__dirname, '..', '..'); } -function getMemoryDir() { +function getWorkspaceRoot() { + if (process.env.OPENCLAW_WORKSPACE) { + return process.env.OPENCLAW_WORKSPACE; + } + const repoRoot = getRepoRoot(); - return process.env.MEMORY_DIR || path.join(repoRoot, 'memory'); + const workspaceDir = path.join(repoRoot, 'workspace'); + if (fs.existsSync(workspaceDir)) { + return workspaceDir; + } + + return path.resolve(__dirname, '..', '..', '..', '..'); +} + +function getLogsDir() { + return process.env.EVOLVER_LOGS_DIR || path.join(getWorkspaceRoot(), 'logs'); +} + +function getEvolverLogPath() { + return path.join(getLogsDir(), 'evolver_loop.log'); +} + +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); + if (!safe || /^\.{1,2}$/.test(safe) || /\.\./.test(safe)) return null; + return safe; +} + +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(); - return process.env.GEP_ASSETS_DIR || path.join(repoRoot, 'assets', 'gep'); + 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'); +} + +function getNarrativePath() { + return path.join(getEvolutionDir(), 'evolution_narrative.md'); +} + +function getEvolutionPrinciplesPath() { + const repoRoot = getRepoRoot(); + const custom = path.join(repoRoot, 'EVOLUTION_PRINCIPLES.md'); + if (fs.existsSync(custom)) return custom; + return path.join(repoRoot, 'assets', 'gep', 'EVOLUTION_PRINCIPLES.md'); +} + +function getReflectionLogPath() { + return path.join(getEvolutionDir(), 'reflection_log.jsonl'); } module.exports = { getRepoRoot, + getWorkspaceRoot, + getLogsDir, + getEvolverLogPath, getMemoryDir, + getEvolutionDir, getGepAssetsDir, + getSkillsDir, + getSessionScope, + getNarrativePath, + getEvolutionPrinciplesPath, + getReflectionLogPath, }; diff --git a/src/gep/personality.js b/src/gep/personality.js index 5d3a3ac..7923dac 100644 --- a/src/gep/personality.js +++ b/src/gep/personality.js @@ -40,7 +40,7 @@ function writeJsonAtomic(filePath, obj) { function personalityFilePath() { const memoryDir = getMemoryDir(); - return path.join(memoryDir, 'personality_state.json'); + const { getEvolutionDir } = require('./paths'); return path.join(getEvolutionDir(), 'personality_state.json'); } function defaultPersonalityState() { diff --git a/src/gep/prompt.js b/src/gep/prompt.js index ce4f40a..2dbe9f0 100644 --- a/src/gep/prompt.js +++ b/src/gep/prompt.js @@ -1,332 +1,566 @@ -function buildGepPrompt({ - nowIso, - context, - signals, - selector, - parentEventId, - selectedGene, - capsuleCandidates, - genesPreview, - capsulesPreview, - capabilityCandidatesPreview, - externalCandidatesPreview, -}) { - const parentValue = parentEventId ? `"${parentEventId}"` : 'null'; - const selectedGeneId = selectedGene && selectedGene.id ? selectedGene.id : null; - const capsuleIds = (capsuleCandidates || []).map(c => c && c.id).filter(Boolean); - - const basePrompt = ` -GEP — GENOME EVOLUTION PROTOCOL (STANDARD EXECUTION) [${nowIso}] - -You are not a chat assistant. -You are not a free agent. -You are a protocol-bound evolution execution engine. - -All actions must comply with this protocol. -Any deviation is a failure even if the outcome appears correct. +const fs = require('fs'); +const { captureEnvFingerprint } = require('./envFingerprint'); +const { formatAssetPreview } = require('./assets'); +const { generateInnovationIdeas } = require('../ops/innovation'); +const { analyzeRecentHistory, OPPORTUNITY_SIGNALS } = require('./signals'); +const { loadNarrativeSummary } = require('./narrativeMemory'); +const { getEvolutionPrinciplesPath } = require('./paths'); + +/** + * 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)} +\`\`\` -━━━━━━━━━━━━━━━━━━━━━━ -I. Protocol Positioning (Non-Negotiable) -━━━━━━━━━━━━━━━━━━━━━━ +IMPORTANT: Do NOT reinvent. Apply faithfully. +`.trim(); +} -Protocol goals: -- Convert reasoning into reusable, auditable, shareable evolution assets -- Make evolution a standard process, not improvisation -- Reduce future reasoning cost for similar problems +/** + * 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(); +} -Protocol compliance overrides local optimality. +/** + * 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 = ` ━━━━━━━━━━━━━━━━━━━━━━ -II. Mandatory Evolution Object Model (All Required) +I. Mandatory Evolution Object Model (Output EXACTLY these 5 objects) ━━━━━━━━━━━━━━━━━━━━━━ -Every evolution run must explicitly output the following five objects. -Missing any one is an immediate failure. - -────────────────────── -0 Mutation -────────────────────── - -You must emit a Mutation object for every evolution run: +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(); -\`\`\`json -{ - "type": "Mutation", - "id": "mut_", - "category": "repair | optimize | innovate", - "trigger_signals": [""], - "target": "", - "expected_effect": "", - "risk_level": "low | medium | high" +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'; } -\`\`\` - -Hard safety constraints: -- Do NOT run high-risk mutation unless rigor >= 0.6 AND risk_tolerance <= 0.5 -- Do NOT combine innovation mutation with a high-risk personality state -────────────────────── -1 PersonalityState -────────────────────── +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); + } + } -You must emit a PersonalityState object for every evolution run: + if (positive.length === 0 && negative.length === 0) return ''; -\`\`\`json -{ - "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 + 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'); } -\`\`\` -Personality mutation (optional, small deltas only): -\`\`\`json -{ - "type": "PersonalityMutation", - "param": "creativity", - "delta": 0.1, - "reason": "" +function buildNarrativeBlock() { + try { + const narrative = loadNarrativeSummary(3000); + if (!narrative) return ''; + return `\nContext [Evolution Narrative] (Recent decisions and outcomes -- learn from this history):\n${narrative}\n`; + } catch (_) { + return ''; + } } -\`\`\` -Constraints: -- Each delta must be within [-0.2, +0.2] -- Do not adjust more than 2 parameters in one run - -────────────────────── -2 EvolutionEvent -────────────────────── - -You must emit an EvolutionEvent with all fields present: -\`\`\`json -{ - "type": "EvolutionEvent", - "id": "evt_", - "parent": ${parentValue}, - "intent": "repair | optimize | innovate", - "signals": ["", ""], - "genes_used": [""], - "mutation_id": "", - "personality_state": { "type": "PersonalityState", "...": "..." }, - "blast_radius": { - "files": , - "lines": - }, - "outcome": { - "status": "success | failed", - "score": <0.0-1.0> +function buildPrinciplesBlock() { + try { + const principlesPath = getEvolutionPrinciplesPath(); + if (!fs.existsSync(principlesPath)) return ''; + const content = fs.readFileSync(principlesPath, 'utf8'); + if (!content.trim()) return ''; + const trimmed = content.length > 2000 ? content.slice(0, 2000) + '\n...[TRUNCATED]' : content; + return `\nContext [Evolution Principles] (Guiding directives -- align your actions):\n${trimmed}\n`; + } catch (_) { + return ''; } } -\`\`\` - -EvolutionEvent is the only legal node type in the evolution tree. -────────────────────── -3 Gene -────────────────────── - -If a Gene is used, you must reuse an existing Gene first. -Only create a new Gene when no match exists. +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]..."; + } -Gene must follow this schema: + // 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]...`); + } -\`\`\`json -{ - "type": "Gene", - "id": "gene_", - "category": "repair | optimize | innovate", - "signals_match": [""], - "preconditions": [""], - "strategy": [ - "", - "" - ], - "constraints": { - "max_files": , - "forbidden_paths": [""] - }, - "validation": ["", ""] -} -\`\`\` + 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')} +`; + } + } -A Gene is an evolution interface definition, not code or generic advice. + // [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; + } -────────────────────── -4 Capsule -────────────────────── + // [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(); + } -Only when evolution succeeds, you must generate a Capsule: + // 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}] -\`\`\`json -{ - "type": "Capsule", - "id": "capsule_", - "trigger": [""], - "gene": "", - "summary": "", - "confidence": <0.0-1.0> -} -\`\`\` +You are a protocol-bound evolution engine. Compliance overrides optimality. -Capsules exist to prevent repeated reasoning for similar problems. +${schemaSection} ━━━━━━━━━━━━━━━━━━━━━━ -III. Standard Evolution Execution (Strict Order) +II. Directives & Logic ━━━━━━━━━━━━━━━━━━━━━━ -Follow this order exactly. Do not skip, merge, or reorder steps: +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. -1 Signal Extraction -- Extract structured signals from logs, errors, metrics, or instructions -- Do not proceed to repair or optimize before signals are extracted +Context [Signals]: +${JSON.stringify(optimizedSignals)} -2 Selection -- Prefer existing Genes first -- Then consider existing Capsules -- No improvisation or trial-and-error strategies +Context [Env Fingerprint]: +${JSON.stringify(envFingerprint, null, 2)} +${innovationBlock} +Context [Injection Hint]: +${process.env.EVOLVE_HINT ? process.env.EVOLVE_HINT : '(none)'} -You must provide a clear, auditable selection rationale. +Context [Gene Preview] (Reference for Strategy): +${formattedGenes} -3 Patch Execution -- All changes must be small and reversible -- blast_radius must be estimated and recorded before edits +Context [Capsule Preview] (Reference for Past Success): +${formattedCapsules} -4 Validation -- Execute Gene-declared validation steps -- On failure, rollback -- Failure must still record an EvolutionEvent +Context [Capability Candidates]: +${capsPreview} -5 Knowledge Solidification (Mandatory) -- Update or add Gene if a new pattern is found -- Generate Capsule on success -- Append EvolutionEvent +Context [Hub Matched Solution]: +${hubMatchedBlock || '(no hub match)'} -If knowledge solidification is missing, the evolution is a failure even if functionality works. +Context [External Candidates]: +${externalCandidatesPreview || '(none)'} +${buildAntiPatternZone(failedCapsules, signals)}${buildLessonsBlock(hubLessons, signals)} +${historyBlock} +${buildNarrativeBlock()} +${buildPrinciplesBlock()} +Context [Execution]: +${executionContext} ━━━━━━━━━━━━━━━━━━━━━━ -IV. Selector (Mandatory Decision Logic) +MANDATORY POST-SOLIDIFY STEP (Wrapper Authority -- Cannot Be Skipped) ━━━━━━━━━━━━━━━━━━━━━━ -When choosing a Gene or Capsule, you must emit a Selector decision. - -Selector must be explainable, for example: +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. -\`\`\`json +Write a JSON file with your status: +\`\`\`bash +cat > ${process.env.WORKSPACE_DIR || '.'}/logs/status_${cycleId}.json << 'STATUSEOF' { - "selected": "${selectedGeneId || ''}", - "reason": [ - "signals exact match", - "historical success rate high", - "low blast radius" - ], - "alternatives": ${JSON.stringify(capsuleIds.length ? capsuleIds : [''])} + "result": "success|failed", + "en": "Status: [INTENT] ", + "zh": "状态: [意图] <用中文描述你做了什么,1-2句>" } +STATUSEOF \`\`\` -Selector is part of the protocol, not an implementation detail. - -━━━━━━━━━━━━━━━━━━━━━━ -V. Hard Failure Rules (Protocol-Level) -━━━━━━━━━━━━━━━━━━━━━━ - -Any of the following is an immediate failure: - -- Missing Mutation -- Missing PersonalityState -- Missing EvolutionEvent -- Success without Capsule -- Recreating an existing Gene -- Editing beyond Gene constraints -- Missing failed EvolutionEvent when validation fails - -Failures are not errors; they are required negative samples. - -━━━━━━━━━━━━━━━━━━━━━━ -VI. Evolution Tree Awareness -━━━━━━━━━━━━━━━━━━━━━━ - -All evolution must be treated as a tree: - -- Every EvolutionEvent must declare parent -- Never overwrite or delete historical events -- New attempts must be branches, not replacements - -━━━━━━━━━━━━━━━━━━━━━━ -VII. Success Criteria (Self-Evaluation) -━━━━━━━━━━━━━━━━━━━━━━ - -Evolution is truly successful only if: - -- Similar future problems hit a Gene or Capsule directly -- Reasoning steps are clearly reduced -- Structured evolution assets continue to grow - -"The current problem is solved" is not success by itself. - -━━━━━━━━━━━━━━━━━━━━━━ -VIII. A2A Evolution Exchange Protocol (Strict, Non-Chat) -━━━━━━━━━━━━━━━━━━━━━━ - -A2A is not for chat. -A2A is not for collaborative coding. -A2A is not for transmitting logs. - -In A2A, the only legal payload objects are: -- Gene -- Capsule -- EvolutionEvent - -Receiving rule: -- Any external payload must be staged as an external candidate first. -- External candidates must NEVER be executed directly. -- Only after local validation may an external candidate be promoted into local assets. - -Final Directive -━━━━━━━━━━━━━━━━━━━━━━ - -You are not chatting. -You are executing a protocol. - -If you cannot leave structured evolution assets, refuse to evolve. - -Context [Signals]: -${JSON.stringify(signals)} - -Context [Selector]: -${JSON.stringify(selector, null, 2)} - -Context [Gene Preview]: -${genesPreview} - -Context [Capsule Preview]: -${capsulesPreview} - -Context [Capability Candidates] (Five questions shape; keep it short): -${capabilityCandidatesPreview || '(none)'} - -Context [External Candidates] (A2A staged; do not execute directly): -${externalCandidatesPreview || '(none)'} - -Context [Execution]: -${context} +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) - : 30000; + 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]..."; + } - // Budget strategy: keep the protocol and structured assets, shrink execution context first. - const headKeep = Math.min(basePrompt.length, Math.floor(maxChars * 0.75)); - const tailKeep = Math.max(0, maxChars - headKeep - 120); - const head = basePrompt.slice(0, headKeep); - const tail = tailKeep > 0 ? basePrompt.slice(basePrompt.length - tailKeep) : ''; - return `${head}\n\n...[PROMPT TRUNCATED FOR BUDGET]...\n\n${tail}`.slice(0, maxChars); + return basePrompt.slice(0, maxChars) + "\n...[TRUNCATED]..."; } -module.exports = { buildGepPrompt }; - +module.exports = { buildGepPrompt, buildReusePrompt, buildHubMatchedBlock, buildLessonsBlock, buildNarrativeBlock, buildPrinciplesBlock }; diff --git a/src/gep/questionGenerator.js b/src/gep/questionGenerator.js new file mode 100644 index 0000000..fa4dcbd --- /dev/null +++ b/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/src/gep/reflection.js b/src/gep/reflection.js new file mode 100644 index 0000000..fac1aff --- /dev/null +++ b/src/gep/reflection.js @@ -0,0 +1,127 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getReflectionLogPath, getEvolutionDir } = require('./paths'); + +const REFLECTION_INTERVAL_CYCLES = 5; +const REFLECTION_COOLDOWN_MS = 30 * 60 * 1000; + +function ensureDir(dir) { + try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch (_) {} +} + +function shouldReflect({ cycleCount, recentEvents }) { + if (!Number.isFinite(cycleCount) || cycleCount < REFLECTION_INTERVAL_CYCLES) return false; + if (cycleCount % REFLECTION_INTERVAL_CYCLES !== 0) return false; + + const logPath = getReflectionLogPath(); + try { + if (fs.existsSync(logPath)) { + const stat = fs.statSync(logPath); + if (Date.now() - stat.mtimeMs < REFLECTION_COOLDOWN_MS) return false; + } + } catch (_) {} + + return true; +} + +function buildReflectionContext({ recentEvents, signals, memoryAdvice, narrative }) { + const parts = ['You are performing a strategic reflection on recent evolution cycles.']; + parts.push('Analyze the patterns below and provide concise strategic guidance.'); + parts.push(''); + + if (Array.isArray(recentEvents) && recentEvents.length > 0) { + const last10 = recentEvents.slice(-10); + const successCount = last10.filter(e => e && e.outcome && e.outcome.status === 'success').length; + const failCount = last10.filter(e => e && e.outcome && e.outcome.status === 'failed').length; + const intents = {}; + last10.forEach(e => { + const i = e && e.intent ? e.intent : 'unknown'; + intents[i] = (intents[i] || 0) + 1; + }); + const genes = {}; + last10.forEach(e => { + const g = e && Array.isArray(e.genes_used) && e.genes_used[0] ? e.genes_used[0] : 'unknown'; + genes[g] = (genes[g] || 0) + 1; + }); + + parts.push('## Recent Cycle Statistics (last 10)'); + parts.push(`- Success: ${successCount}, Failed: ${failCount}`); + parts.push(`- Intent distribution: ${JSON.stringify(intents)}`); + parts.push(`- Gene usage: ${JSON.stringify(genes)}`); + parts.push(''); + } + + if (Array.isArray(signals) && signals.length > 0) { + parts.push('## Current Signals'); + parts.push(signals.slice(0, 20).join(', ')); + parts.push(''); + } + + if (memoryAdvice) { + parts.push('## Memory Graph Advice'); + if (memoryAdvice.preferredGeneId) { + parts.push(`- Preferred gene: ${memoryAdvice.preferredGeneId}`); + } + if (Array.isArray(memoryAdvice.bannedGeneIds) && memoryAdvice.bannedGeneIds.length > 0) { + parts.push(`- Banned genes: ${memoryAdvice.bannedGeneIds.join(', ')}`); + } + if (memoryAdvice.explanation) { + parts.push(`- Explanation: ${memoryAdvice.explanation}`); + } + parts.push(''); + } + + if (narrative) { + parts.push('## Recent Evolution Narrative'); + parts.push(String(narrative).slice(0, 3000)); + parts.push(''); + } + + parts.push('## Questions to Answer'); + parts.push('1. Are there persistent signals being ignored?'); + parts.push('2. Is the gene selection strategy optimal, or are we stuck in a local maximum?'); + parts.push('3. Should the balance between repair/optimize/innovate shift?'); + parts.push('4. Are there capability gaps that no current gene addresses?'); + parts.push('5. What single strategic adjustment would have the highest impact?'); + parts.push(''); + parts.push('Respond with a JSON object: { "insights": [...], "strategy_adjustment": "...", "priority_signals": [...] }'); + + return parts.join('\n'); +} + +function recordReflection(reflection) { + const logPath = getReflectionLogPath(); + ensureDir(path.dirname(logPath)); + + const entry = JSON.stringify({ + ts: new Date().toISOString(), + type: 'reflection', + ...reflection, + }) + '\n'; + + fs.appendFileSync(logPath, entry, 'utf8'); +} + +function loadRecentReflections(count) { + const n = Number.isFinite(count) ? count : 3; + const logPath = getReflectionLogPath(); + try { + if (!fs.existsSync(logPath)) return []; + const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean); + return lines.slice(-n).map(line => { + try { return JSON.parse(line); } catch (_) { return null; } + }).filter(Boolean); + } catch (_) { + return []; + } +} + +module.exports = { + shouldReflect, + buildReflectionContext, + recordReflection, + loadRecentReflections, + REFLECTION_INTERVAL_CYCLES, +}; diff --git a/src/gep/sanitize.js b/src/gep/sanitize.js new file mode 100644 index 0000000..595aa8d --- /dev/null +++ b/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/src/gep/selector.js b/src/gep/selector.js index 3e22605..b29886c 100644 --- a/src/gep/selector.js +++ b/src/gep/selector.js @@ -3,6 +3,7 @@ function matchPatternToSignals(pattern, signals) { const p = String(pattern); const sig = signals.map(s => String(s)); + // Regex pattern: /body/flags const regexLike = p.length >= 2 && p.startsWith('/') && p.lastIndexOf('/') > 0; if (regexLike) { const lastSlash = p.lastIndexOf('/'); @@ -16,6 +17,12 @@ function matchPatternToSignals(pattern, signals) { } } + // Multi-language alias: "en_term|zh_term|ja_term" -- any branch matching = hit + if (p.includes('|') && !p.startsWith('/')) { + const branches = p.split('|').map(b => b.trim().toLowerCase()).filter(Boolean); + return branches.some(needle => sig.some(s => s.toLowerCase().includes(needle))); + } + const needle = p.toLowerCase(); return sig.some(s => s.toLowerCase().includes(needle)); } @@ -31,38 +38,100 @@ function scoreGene(gene, signals) { 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 genesList = Array.isArray(genes) ? genes : []; const bannedGeneIds = opts && opts.bannedGeneIds ? opts.bannedGeneIds : new Set(); const driftEnabled = !!(opts && opts.driftEnabled); const preferredGeneId = opts && typeof opts.preferredGeneId === 'string' ? opts.preferredGeneId : null; - const scored = genes - .map(g => ({ gene: g, score: scoreGene(g, signals) })) + // Compute continuous drift intensity based on effective population size + var driftIntensity = computeDriftIntensity({ + driftEnabled: driftEnabled, + effectivePopulationSize: opts && opts.effectivePopulationSize, + genePoolSize: genesList.length, + }); + var useDrift = driftEnabled || driftIntensity > 0.15; + + var DISTILLED_PREFIX = 'gene_distilled_'; + var DISTILLED_SCORE_FACTOR = 0.8; + + const scored = genesList + .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: [] }; + 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 && (driftEnabled || !bannedGeneIds.has(preferredGeneId))) { + if (preferred && (useDrift || !bannedGeneIds.has(preferredGeneId))) { const rest = scored.filter(x => x.gene && x.gene.id !== preferredGeneId); - const filteredRest = driftEnabled ? rest : rest.filter(x => x.gene && !bannedGeneIds.has(x.gene.id)); + 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 explicit. - const filtered = driftEnabled ? 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) }; + // 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[0].gene, - alternatives: filtered.slice(1, 4).map(x => x.gene), + selected: filtered[selectedIdx].gene, + alternatives: filtered.filter(function(_, i) { return i !== selectedIdx; }).slice(0, 4).map(x => x.gene), + driftIntensity: driftIntensity, }; } @@ -78,13 +147,54 @@ function selectCapsule(capsules, signals) { return scored.length ? scored[0].capsule : null; } -function selectGeneAndCapsule({ genes, capsules, signals, memoryAdvice, driftEnabled }) { +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; - const { selected, alternatives } = selectGene(genes, signals, { - bannedGeneIds, + var effectiveBans = banGenesFromFailedCapsules( + Array.isArray(failedCapsules) ? failedCapsules : [], + signals, + bannedGeneIds + ); + + const { selected, alternatives, driftIntensity } = selectGene(genes, signals, { + bannedGeneIds: effectiveBans, preferredGeneId, driftEnabled: !!driftEnabled, }); @@ -96,15 +206,17 @@ function selectGeneAndCapsule({ genes, capsules, signals, memoryAdvice, driftEna alternatives, memoryAdvice, driftEnabled, + driftIntensity, }); return { selectedGene: selected, capsuleCandidates: capsule ? [capsule] : [], selector, + driftIntensity, }; } -function buildSelectorDecision({ gene, capsule, signals, alternatives, memoryAdvice, driftEnabled }) { +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'); @@ -117,6 +229,9 @@ function buildSelectorDecision({ gene, capsule, signals, alternatives, memoryAdv 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, @@ -130,5 +245,6 @@ module.exports = { selectGene, selectCapsule, buildSelectorDecision, + matchPatternToSignals, }; diff --git a/src/gep/signals.js b/src/gep/signals.js index a73b9b3..95268d6 100644 --- a/src/gep/signals.js +++ b/src/gep/signals.js @@ -6,17 +6,135 @@ var OPPORTUNITY_SIGNALS = [ '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++) { - if (list.includes(OPPORTUNITY_SIGNALS[i])) return true; + 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; } -function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, userSnippet }) { +// 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 || ''), @@ -26,9 +144,14 @@ function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, user ].join('\n'); var lower = corpus.toLowerCase(); + // Analyze recent evolution history for de-duplication + var history = analyzeRecentHistory(recentEvents || []); + // --- Defensive signals (errors, missing resources) --- - var errorHit = /\[error|error:|exception|fail|failed|iserror":true/.test(lower); + // 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). @@ -39,7 +162,7 @@ function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, user .filter(Boolean); var errLine = - lines.find(function (l) { return /\b(typeerror|referenceerror|syntaxerror)\b\s*:|error\s*:|exception\s*:|\[error/i.test(l); }) || + 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) { @@ -52,28 +175,96 @@ function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, user 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'); + // 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. - // user_feature_request: user explicitly asks for a new capability - // Look for action verbs + object patterns that indicate a feature request - if (/\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)) { - signals.push('user_feature_request'); + 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'; } - // Also catch direct "I want/need X" patterns - if (/\b(i want|i need|we need|please add|can you add|could you add|let'?s add)\b/i.test(lower)) { - signals.push('user_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: user suggests making something better - if (/\b(should be|could be better|improve|enhance|upgrade|refactor|clean up|simplify|streamline)\b/i.test(lower)) { - // Only fire if there is no active error (to distinguish from repair requests) - if (!errorHit) signals.push('user_improvement_suggestion'); + // 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 @@ -89,7 +280,138 @@ function extractSignals({ recentSessionTranscript, todayLog, memorySnippet, user } } + // --- 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, OPPORTUNITY_SIGNALS }; +module.exports = { extractSignals, hasOpportunitySignal, analyzeRecentHistory, OPPORTUNITY_SIGNALS }; diff --git a/src/gep/skillDistiller.js b/src/gep/skillDistiller.js new file mode 100644 index 0000000..be704a3 --- /dev/null +++ b/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 (Genome Evolution 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/src/gep/solidify.js b/src/gep/solidify.js index bfafa3a..82f80d4 100644 --- a/src/gep/solidify.js +++ b/src/gep/solidify.js @@ -1,10 +1,10 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { loadGenes, upsertGene, appendEventJsonl, appendCapsule, upsertCapsule, getLastEventId } = require('./assetStore'); +const { loadGenes, upsertGene, appendEventJsonl, appendCapsule, upsertCapsule, getLastEventId, appendFailedCapsule } = require('./assetStore'); const { computeSignalKey, memoryGraphPath } = require('./memoryGraph'); const { computeCapsuleSuccessStreak, isBlastRadiusSafe } = require('./a2a'); -const { getRepoRoot, getMemoryDir } = require('./paths'); +const { getRepoRoot, getMemoryDir, getEvolutionDir, getWorkspaceRoot } = require('./paths'); const { extractSignals } = require('./signals'); const { selectGene } = require('./selector'); const { isValidMutation, normalizeMutation, isHighRiskMutationAllowed, isHighRiskPersonality } = require('./mutation'); @@ -17,6 +17,9 @@ const { const { computeAssetId, SCHEMA_VERSION } = require('./contentHash'); const { captureEnvFingerprint } = require('./envFingerprint'); const { buildValidationReport } = require('./validationReport'); +const { logAssetCall } = require('./assetCallLog'); +const { recordNarrative } = require('./narrativeMemory'); +const { isLlmReviewEnabled, runLlmReview } = require('./llmReview'); function nowIso() { return new Date().toISOString(); @@ -60,7 +63,7 @@ function stableHash(input) { 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 }); + return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: timeoutMs, windowsHide: true }); } function tryRunCmd(cmd, opts = {}) { @@ -85,21 +88,6 @@ function gitListChangedFiles({ repoRoot }) { return Array.from(files); } -function parseNumstat(text) { - const lines = String(text || '').split('\n').map(l => l.trim()).filter(Boolean); - let added = 0; - let deleted = 0; - 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]); - if (Number.isFinite(a)) added += a; - if (Number.isFinite(d)) deleted += d; - } - return { added, deleted }; -} - function countFileLines(absPath) { try { if (!fs.existsSync(absPath)) return 0; @@ -113,30 +101,149 @@ function countFileLines(absPath) { } } +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 }) { - let changedFiles = gitListChangedFiles({ repoRoot }); + const policy = readOpenclawConstraintPolicy(); + let changedFiles = gitListChangedFiles({ repoRoot }).map(normalizeRelPath).filter(Boolean); if (Array.isArray(baselineUntracked) && baselineUntracked.length > 0) { - const baselineSet = new Set(baselineUntracked); + const baselineSet = new Set(baselineUntracked.map(normalizeRelPath)); changedFiles = changedFiles.filter(f => !baselineSet.has(f)); } - const filesCount = changedFiles.length; + 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 unstaged = u.ok ? parseNumstat(u.out) : { added: 0, deleted: 0 }; - const staged = c.ok ? parseNumstat(c.out) : { added: 0, deleted: 0 }; + 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(l => l.trim()).filter(Boolean); - const baselineSet = new Set(Array.isArray(baselineUntracked) ? baselineUntracked : []); + 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 = unstaged.added + unstaged.deleted + staged.added + staged.deleted + untrackedLines; - return { files: filesCount, lines: churn, changed_files: changedFiles }; + const churn = stagedUnstagedChurn + untrackedLines; + return { + files: filesCount, + lines: churn, + changed_files: countedFiles, + ignored_files: ignoredFiles, + all_changed_files: changedFiles, + }; } function isForbiddenPath(relPath, forbiddenPaths) { @@ -151,30 +258,127 @@ function isForbiddenPath(relPath, forbiddenPaths) { return false; } -function checkConstraints({ gene, blast }) { +function checkConstraints({ gene, blast, blastRadiusEstimate, repoRoot }) { const violations = []; - if (!gene || gene.type !== 'Gene') return { ok: true, violations }; + const warnings = []; + let blastSeverity = null; + + if (!gene || gene.type !== 'Gene') return { ok: true, violations, warnings, blastSeverity }; const constraints = gene.constraints || {}; - const maxFiles = Number(constraints.max_files); - if (Number.isFinite(maxFiles) && maxFiles > 0) { - if (Number(blast.files) > maxFiles) violations.push(`max_files exceeded: ${blast.files} > ${maxFiles}`); + 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.changed_files || []) { + for (const f of blast.all_changed_files || blast.changed_files || []) { if (isForbiddenPath(f, forbidden)) violations.push(`forbidden_path touched: ${f}`); } - return { ok: violations.length === 0, violations }; + + // --- 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(memoryDir, 'evolution_solidify_state.json'); + 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(memoryDir, 'evolution_solidify_state.json'); + const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json'); try { if (!fs.existsSync(memoryDir)) fs.mkdirSync(memoryDir, { recursive: true }); } catch {} @@ -193,6 +397,175 @@ function buildCapsuleId(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 ']; @@ -203,6 +576,7 @@ function isValidationCommandAllowed(cmd) { 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; } @@ -226,7 +600,89 @@ function runValidations(gene, opts = {}) { 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) { + const mode = String(process.env.EVOLVER_ROLLBACK_MODE || 'hard').toLowerCase(); + + if (mode === 'none') { + console.log('[Rollback] EVOLVER_ROLLBACK_MODE=none, skipping rollback'); + return; + } + + if (mode === 'stash') { + const stashRef = 'evolver-rollback-' + Date.now(); + const result = tryRunCmd('git stash push -m "' + stashRef + '" --include-untracked', { cwd: repoRoot, timeoutMs: 60000 }); + if (result.ok) { + console.log('[Rollback] Changes stashed with ref: ' + stashRef + '. Recover with "git stash list" and "git stash pop".'); + } else { + console.log('[Rollback] Stash failed or no changes, using hard reset'); + tryRunCmd('git restore --staged --worktree .', { cwd: repoRoot, timeoutMs: 60000 }); + tryRunCmd('git reset --hard', { cwd: repoRoot, timeoutMs: 60000 }); + } + return; + } + + console.log('[Rollback] EVOLVER_ROLLBACK_MODE=hard, resetting tracked files in: ' + repoRoot); tryRunCmd('git restore --staged --worktree .', { cwd: repoRoot, timeoutMs: 60000 }); tryRunCmd('git reset --hard', { cwd: repoRoot, timeoutMs: 60000 }); } @@ -241,18 +697,65 @@ 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); + if (fs.existsSync(normAbs) && fs.statSync(normAbs).isFile()) { + fs.unlinkSync(normAbs); + deleted.push(safeRel); + } } catch (e) {} } - return { deleted: toDelete }; + 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. + // SAFETY: never remove top-level structural directories (skills/, src/, etc.) + // or critical protected directories. Only remove leaf subdirectories. + var dirsToCheck = new Set(); + for (var di = 0; di < deleted.length; di++) { + var dir = path.dirname(deleted[di]); + while (dir && dir !== '.' && dir !== '/') { + var normalized = dir.replace(/\\/g, '/'); + if (!normalized.includes('/')) break; + 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++) { + if (isCriticalProtectedPath(sortedDirs[si] + '/')) continue; + 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) { @@ -262,6 +765,161 @@ function inferCategoryFromSignals(signals) { 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); @@ -285,8 +943,18 @@ function buildAutoGene({ signals, intent }) { '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: ['node -e "require(\'./src/gep/solidify\'); console.log(\'ok\')"'], + 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; @@ -318,8 +986,32 @@ function readRecentSessionInputs() { return { recentSessionTranscript, todayLog: todayLogContent, memorySnippet, userSnippet }; } +function isGitRepo(dir) { + try { + execSync('git rev-parse --git-dir', { + cwd: dir, encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, + }); + return true; + } catch (_) { + return false; + } +} + function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } = {}) { const repoRoot = getRepoRoot(); + + if (!isGitRepo(repoRoot)) { + console.error('[Solidify] FATAL: Not a git repository (' + repoRoot + ').'); + console.error('[Solidify] Solidify requires git for rollback, diff capture, and blast radius.'); + console.error('[Solidify] Run "git init && git add -A && git commit -m init" first.'); + return { + ok: false, + status: 'failed', + failure_reason: 'not_a_git_repository', + event: null, + }; + } const state = readStateForSolidify(); const lastRun = state && state.last_run ? state.last_run : null; const genes = loadGenes(); @@ -359,7 +1051,41 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } repoRoot, baselineUntracked: lastRun && Array.isArray(lastRun.baseline_untracked) ? lastRun.baseline_untracked : [], }); - const constraintCheck = checkConstraints({ gene: geneUsed, blast }); + 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(); @@ -369,6 +1095,40 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } 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}`); + } + + // Optional LLM review: when EVOLVER_LLM_REVIEW=true, submit diff for review. + let llmReviewResult = null; + if (constraintCheck.ok && validation.ok && protocolViolations.length === 0 && isLlmReviewEnabled()) { + try { + const reviewDiff = captureDiffSnapshot(repoRoot); + llmReviewResult = runLlmReview({ + diff: reviewDiff, + gene: geneUsed, + signals, + mutation, + }); + if (llmReviewResult && llmReviewResult.approved === false) { + constraintCheck.violations.push('llm_review_rejected: ' + (llmReviewResult.summary || 'no reason')); + constraintCheck.ok = false; + console.log('[LLMReview] Change REJECTED: ' + (llmReviewResult.summary || '')); + } else if (llmReviewResult) { + console.log('[LLMReview] Change approved (confidence: ' + (llmReviewResult.confidence || '?') + ')'); + } + } catch (e) { + console.log('[LLMReview] Failed (non-fatal): ' + (e && e.message ? e.message : e)); + } + } + // Build standardized ValidationReport (machine-readable, interoperable). const validationReport = buildValidationReport({ geneId: geneUsed && geneUsed.id ? geneUsed.id : null, @@ -393,6 +1153,13 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } 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, @@ -406,6 +1173,9 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } 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: { @@ -426,9 +1196,17 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } }, 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(), @@ -449,6 +1227,11 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } 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, @@ -460,16 +1243,67 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } 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); } - // Bug fix: dry-run must NOT trigger rollback (it should only observe, not mutate). + // 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 : [] }); + // Only clean up new untracked files when a valid baseline exists. + // Without a baseline, we cannot distinguish pre-existing untracked files + // from AI-generated ones, so deleting would be destructive. + if (lastRun && Array.isArray(lastRun.baseline_untracked)) { + rollbackNewUntrackedFiles({ repoRoot, baselineUntracked: 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) { @@ -501,12 +1335,347 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } }; if (!dryRun) writeStateForSolidify(state); - return { ok: success, event, capsule, gene: geneUsed, constraintCheck, validation, validationReport, blast }; + if (!dryRun) { + try { + recordNarrative({ + gene: geneUsed, + signals, + mutation, + outcome: event.outcome, + blast, + capsule, + }); + } catch (e) { + console.log('[Narrative] Record failed (non-fatal): ' + (e && e.message ? e.message : e)); + } + } + + // 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 }; + logAssetCall({ + run_id: lastRun && lastRun.run_id ? lastRun.run_id : null, + action: 'asset_publish', + asset_id: capsule.asset_id || capsule.id, + asset_type: 'Capsule', + source_node_id: null, + chain_id: publishChainId || null, + signals: Array.isArray(capsule.trigger) ? capsule.trigger : [], + extra: { + source_type: sourceType, + reused_asset_id: reusedAssetId, + gene_id: publishGene && publishGene.id ? publishGene.id : null, + parent: parentRef || null, + }, + }); + } 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 }; + logAssetCall({ + run_id: lastRun && lastRun.run_id ? lastRun.run_id : null, + action: 'asset_publish_skip', + asset_id: capsule.asset_id || capsule.id, + asset_type: 'Capsule', + reason, + signals: Array.isArray(capsule.trigger) ? capsule.trigger : [], + }); + } + } + + // --- 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) { + const workerAssignmentId = lastRun.worker_assignment_id || null; + const workerPending = lastRun.worker_pending || false; + if (workerPending && !workerAssignmentId) { + // Deferred claim mode: claim + complete atomically now that we have a result + try { + const { claimAndCompleteWorkerTask } = require('./taskReceiver'); + const taskId = String(lastRun.active_task_id); + console.log(`[WorkerPool] Atomic claim+complete for task "${lastRun.active_task_title || taskId}" with asset ${resultAssetId}`); + const result = claimAndCompleteWorkerTask(taskId, resultAssetId); + if (result && typeof result.then === 'function') { + result + .then(function (r) { + if (r.ok) { + console.log('[WorkerPool] Claim+complete succeeded, assignment=' + r.assignment_id); + } else { + console.log('[WorkerPool] Claim+complete failed: ' + (r.error || 'unknown') + (r.assignment_id ? ' assignment=' + r.assignment_id : '')); + } + }) + .catch(function (err) { + console.log('[WorkerPool] Claim+complete error (non-fatal): ' + (err && err.message ? err.message : err)); + }); + } + taskCompleteResult = { attempted: true, task_id: lastRun.active_task_id, asset_id: resultAssetId, worker: true, deferred: true }; + } catch (e) { + console.log('[WorkerPool] Atomic claim+complete error (non-fatal): ' + e.message); + taskCompleteResult = { attempted: false, reason: e.message, worker: true, deferred: true }; + } + } else if (workerAssignmentId) { + // Legacy path: already-claimed assignment, just complete it + try { + const { completeWorkerTask } = require('./taskReceiver'); + console.log(`[WorkerComplete] Completing worker assignment "${workerAssignmentId}" with asset ${resultAssetId}`); + const completed = completeWorkerTask(workerAssignmentId, resultAssetId); + if (completed && typeof completed.then === 'function') { + completed + .then(function (ok) { + if (ok) { + console.log('[WorkerComplete] Worker task completed successfully on Hub.'); + } else { + console.log('[WorkerComplete] Hub rejected worker completion (non-fatal).'); + } + }) + .catch(function (err) { + console.log('[WorkerComplete] Failed (non-fatal): ' + (err && err.message ? err.message : err)); + }); + } + taskCompleteResult = { attempted: true, task_id: lastRun.active_task_id, assignment_id: workerAssignmentId, asset_id: resultAssetId, worker: true }; + } catch (e) { + console.log('[WorkerComplete] Error (non-fatal): ' + e.message); + taskCompleteResult = { attempted: false, reason: e.message, worker: true }; + } + } else { + // Bounty task path: complete via /a2a/task/complete + 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 }; + } + } + } + } + + + // --- Auto Hub Review: rate fetched assets based on solidify outcome --- + // When this cycle reused a Hub asset, submit a usage-verified review. + // The promise is returned so callers can await it before process.exit(). + var hubReviewResult = null; + var hubReviewPromise = null; + if (!dryRun && reusedAssetId && (sourceType === 'reused' || sourceType === 'reference')) { + try { + var { submitHubReview } = require('./hubReview'); + hubReviewPromise = submitHubReview({ + reusedAssetId: reusedAssetId, + sourceType: sourceType, + outcome: event.outcome, + gene: geneUsed, + signals: signals, + blast: blast, + constraintCheck: constraintCheck, + runId: lastRun && lastRun.run_id ? lastRun.run_id : null, + }); + if (hubReviewPromise && typeof hubReviewPromise.then === 'function') { + hubReviewPromise = hubReviewPromise + .then(function (r) { + hubReviewResult = r; + if (r && r.submitted) { + console.log('[HubReview] Review submitted successfully (rating=' + r.rating + ').'); + } + return r; + }) + .catch(function (err) { + console.log('[HubReview] Error (non-fatal): ' + (err && err.message ? err.message : err)); + return null; + }); + } + } catch (e) { + console.log('[HubReview] Error (non-fatal): ' + e.message); + } + } + return { ok: success, event, capsule, gene: geneUsed, constraintCheck, validation, validationReport, blast, publishResult, antiPatternPublishResult, taskCompleteResult, hubReviewResult, hubReviewPromise }; } module.exports = { solidify, + isGitRepo, 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/src/gep/strategy.js b/src/gep/strategy.js new file mode 100644 index 0000000..c35a12a --- /dev/null +++ b/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/src/gep/taskReceiver.js b/src/gep/taskReceiver.js new file mode 100644 index 0000000..2023140 --- /dev/null +++ b/src/gep/taskReceiver.js @@ -0,0 +1,528 @@ +// --------------------------------------------------------------------------- +// 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, buildHubHeaders } = require('./a2aProtocol'); + +const HUB_URL = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || 'https://evomap.ai'; + +function buildAuthHeaders() { + return buildHubHeaders(); +} + +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: buildAuthHeaders(), + 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 (err) { + console.warn("[TaskReceiver] fetchTasks failed:", err && err.message ? err.message : err); + 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); +} + +// --------------------------------------------------------------------------- +// Commitment deadline estimation -- based on task difficulty +// --------------------------------------------------------------------------- + +const MIN_COMMITMENT_MS = 5 * 60 * 1000; // 5 min (Hub minimum) +const MAX_COMMITMENT_MS = 24 * 60 * 60 * 1000; // 24 h (Hub maximum) + +const DIFFICULTY_DURATION_MAP = [ + { threshold: 0.3, durationMs: 15 * 60 * 1000 }, // low: 15 min + { threshold: 0.5, durationMs: 30 * 60 * 1000 }, // medium: 30 min + { threshold: 0.7, durationMs: 60 * 60 * 1000 }, // high: 60 min + { threshold: 1.0, durationMs: 120 * 60 * 1000 }, // very high: 120 min +]; + +/** + * Estimate a reasonable commitment deadline for a task. + * Returns an ISO-8601 date string or null if estimation fails. + * + * @param {object} task - task from Hub + * @returns {string|null} + */ +function estimateCommitmentDeadline(task) { + if (!task) return null; + + var difficulty = (task.complexity_score != null) + ? Number(task.complexity_score) + : localDifficultyEstimate(task); + + var durationMs = DIFFICULTY_DURATION_MAP[DIFFICULTY_DURATION_MAP.length - 1].durationMs; + for (var i = 0; i < DIFFICULTY_DURATION_MAP.length; i++) { + if (difficulty <= DIFFICULTY_DURATION_MAP[i].threshold) { + durationMs = DIFFICULTY_DURATION_MAP[i].durationMs; + break; + } + } + + durationMs = Math.max(MIN_COMMITMENT_MS, Math.min(MAX_COMMITMENT_MS, durationMs)); + + var deadline = new Date(Date.now() + durationMs); + + if (task.expires_at) { + var expiresAt = new Date(task.expires_at); + if (!isNaN(expiresAt.getTime()) && expiresAt < deadline) { + var remaining = expiresAt.getTime() - Date.now(); + if (remaining < MIN_COMMITMENT_MS) return null; + var adjusted = new Date(expiresAt.getTime() - 60000); + if (adjusted.getTime() - Date.now() < MIN_COMMITMENT_MS) return null; + deadline = adjusted; + } + } + + return deadline.toISOString(); +} + +// --------------------------------------------------------------------------- +// 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 + * @param {{ commitment_deadline?: string }} [opts] + * @returns {boolean} true if claim succeeded + */ +async function claimTask(taskId, opts) { + 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 body = { task_id: taskId, node_id: nodeId }; + if (opts && opts.commitment_deadline) { + body.commitment_deadline = opts.commitment_deadline; + } + + const res = await fetch(url, { + method: 'POST', + headers: buildAuthHeaders(), + body: JSON.stringify(body), + 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: buildAuthHeaders(), + 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; +} + +// --------------------------------------------------------------------------- +// Worker Pool task operations (POST /a2a/work/*) +// These use a separate API from bounty tasks and return assignment objects. +// --------------------------------------------------------------------------- + +async function claimWorkerTask(taskId) { + const nodeId = getNodeId(); + if (!nodeId || !taskId) return null; + + try { + const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/work/claim`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + method: 'POST', + headers: buildAuthHeaders(), + body: JSON.stringify({ task_id: taskId, node_id: nodeId }), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function completeWorkerTask(assignmentId, resultAssetId) { + const nodeId = getNodeId(); + if (!nodeId || !assignmentId || !resultAssetId) return false; + + try { + const url = `${HUB_URL.replace(/\/+$/, '')}/a2a/work/complete`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + method: 'POST', + headers: buildAuthHeaders(), + body: JSON.stringify({ assignment_id: assignmentId, node_id: nodeId, result_asset_id: resultAssetId }), + signal: controller.signal, + }); + clearTimeout(timer); + + return res.ok; + } catch { + return false; + } +} + +/** + * Atomic claim+complete for deferred worker tasks. + * Called from solidify after a successful evolution cycle so we never hold + * an assignment that might expire before completion. + * + * @param {string} taskId + * @param {string} resultAssetId - sha256:... of the published capsule + * @returns {{ ok: boolean, assignment_id?: string, error?: string }} + */ +async function claimAndCompleteWorkerTask(taskId, resultAssetId) { + const nodeId = getNodeId(); + if (!nodeId || !taskId || !resultAssetId) { + return { ok: false, error: 'missing_params' }; + } + + const assignment = await claimWorkerTask(taskId); + if (!assignment) { + return { ok: false, error: 'claim_failed' }; + } + + const assignmentId = assignment.id || assignment.assignment_id; + if (!assignmentId) { + return { ok: false, error: 'no_assignment_id' }; + } + + const completed = await completeWorkerTask(assignmentId, resultAssetId); + if (!completed) { + console.warn(`[WorkerPool] Claimed assignment ${assignmentId} but complete failed -- will expire on Hub`); + return { ok: false, error: 'complete_failed', assignment_id: assignmentId }; + } + + return { ok: true, assignment_id: assignmentId }; +} + +module.exports = { + fetchTasks, + selectBestTask, + estimateCapabilityMatch, + scoreTask, + claimTask, + completeTask, + taskToSignals, + claimWorkerTask, + completeWorkerTask, + claimAndCompleteWorkerTask, + estimateCommitmentDeadline, +}; diff --git a/src/gep/validationReport.js b/src/gep/validationReport.js index 68d0207..cafb0d2 100644 --- a/src/gep/validationReport.js +++ b/src/gep/validationReport.js @@ -26,8 +26,8 @@ function buildValidationReport({ geneId, commands, results, envFp, startedAt, fi return { command: String(cmd || ''), ok: !!r.ok, - stdout: String(r.out || '').slice(0, 4000), - stderr: String(r.err || '').slice(0, 4000), + 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, diff --git a/src/ops/cleanup.js b/src/ops/cleanup.js new file mode 100644 index 0000000..71cd4fa --- /dev/null +++ b/src/ops/cleanup.js @@ -0,0 +1,80 @@ +// 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 safeBatchDelete(batch) { + var deleted = 0; + for (var i = 0; i < batch.length; i++) { + try { fs.unlinkSync(batch[i]); deleted++; } catch (_) {} + } + return deleted; +} + +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) + var 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) { + deleted += safeBatchDelete(filesToDelete); + } + + // Phase 2: Size-based safety cap (keep max 10 files total) + 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 + + var MAX_FILES = 10; + if (remainingFiles.length > MAX_FILES) { + var toDelete = remainingFiles.slice(MAX_FILES).map(function(f) { return f.path; }); + deleted += safeBatchDelete(toDelete); + } + } 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/src/ops/commentary.js b/src/ops/commentary.js new file mode 100644 index 0000000..8f3aeed --- /dev/null +++ b/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/src/ops/health_check.js b/src/ops/health_check.js new file mode 100644 index 0000000..9b3ac76 --- /dev/null +++ b/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/src/ops/index.js b/src/ops/index.js new file mode 100644 index 0000000..65157e2 --- /dev/null +++ b/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/src/ops/innovation.js b/src/ops/innovation.js new file mode 100644 index 0000000..e1bb641 --- /dev/null +++ b/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/src/ops/lifecycle.js b/src/ops/lifecycle.js new file mode 100644 index 0000000..82b6ecb --- /dev/null +++ b/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, getEvolverLogPath } = require('../gep/paths'); + +var WORKSPACE_ROOT = getWorkspaceRoot(); +var LOG_FILE = getEvolverLogPath(); +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/src/ops/self_repair.js b/src/ops/self_repair.js new file mode 100644 index 0000000..69eb527 --- /dev/null +++ b/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/src/ops/skills_monitor.js b/src/ops/skills_monitor.js new file mode 100644 index 0000000..aea4e0e --- /dev/null +++ b/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/src/ops/trigger.js b/src/ops/trigger.js new file mode 100644 index 0000000..954940c --- /dev/null +++ b/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/test/Dockerfile b/test/Dockerfile deleted file mode 100644 index 7bf2bda..0000000 --- a/test/Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -FROM node:22-bookworm - -# Create OpenClaw-compatible directory structure -RUN mkdir -p /home/node/.openclaw/agents/main/sessions \ - && mkdir -p /workspace/skills/evolver/memory - -# Working directory matches OpenClaw convention -WORKDIR /workspace/skills/evolver - -# Copy the evolver skill (build output) -COPY dist-public/ /workspace/skills/evolver/ - -# Copy test infrastructure -COPY test/fixtures/ /workspace/skills/evolver/test/fixtures/ -COPY test/vibe_test.js /workspace/skills/evolver/test/vibe_test.js -COPY test/llm_helper.js /workspace/skills/evolver/test/llm_helper.js - -# Place mock session log where evolve.js expects it -COPY test/fixtures/session_mock.jsonl /home/node/.openclaw/agents/main/sessions/mock_session.jsonl - -# Initialize a git repo so solidify's git commands work. -# Must happen before chown, and we mark directory as safe for all users. -RUN cd /workspace/skills/evolver \ - && git init \ - && git config --global --add safe.directory /workspace/skills/evolver \ - && git config user.email "vibe-test@local" \ - && git config user.name "vibe-test" \ - && git add -A \ - && git commit -m "init" --quiet - -# Ensure node user owns everything and can use git -RUN chown -R node:node /workspace /home/node/.openclaw /workspace/skills/evolver/.git - -USER node - -# Mark safe directory for node user too -RUN git config --global --add safe.directory /workspace/skills/evolver - -ENV AGENT_NAME=main -ENV EVOLVE_BRIDGE=false -ENV HOME=/home/node -ENV NODE_ENV=test - -CMD ["node", "test/vibe_test.js"] diff --git a/test/a2aProtocol.test.js b/test/a2aProtocol.test.js new file mode 100644 index 0000000..fc145f7 --- /dev/null +++ b/test/a2aProtocol.test.js @@ -0,0 +1,199 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + PROTOCOL_NAME, + PROTOCOL_VERSION, + VALID_MESSAGE_TYPES, + buildMessage, + buildHello, + buildPublish, + buildFetch, + buildReport, + buildDecision, + buildRevoke, + isValidProtocolMessage, + unwrapAssetFromMessage, + sendHeartbeat, +} = 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); + }); +}); + +describe('sendHeartbeat log touch', () => { + var tmpDir; + var originalFetch; + var originalHubUrl; + var originalLogsDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-hb-test-')); + originalHubUrl = process.env.A2A_HUB_URL; + originalLogsDir = process.env.EVOLVER_LOGS_DIR; + process.env.A2A_HUB_URL = 'http://localhost:19999'; + process.env.EVOLVER_LOGS_DIR = tmpDir; + originalFetch = global.fetch; + }); + + after(() => { + global.fetch = originalFetch; + if (originalHubUrl === undefined) { + delete process.env.A2A_HUB_URL; + } else { + process.env.A2A_HUB_URL = originalHubUrl; + } + if (originalLogsDir === undefined) { + delete process.env.EVOLVER_LOGS_DIR; + } else { + process.env.EVOLVER_LOGS_DIR = originalLogsDir; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates mtime of existing evolver_loop.log on successful heartbeat', async () => { + var logPath = path.join(tmpDir, 'evolver_loop.log'); + fs.writeFileSync(logPath, ''); + var oldTime = new Date(Date.now() - 5000); + fs.utimesSync(logPath, oldTime, oldTime); + + global.fetch = async () => ({ + json: async () => ({ status: 'ok' }), + }); + + var result = await sendHeartbeat(); + assert.ok(result.ok, 'heartbeat should succeed'); + + var mtime = fs.statSync(logPath).mtimeMs; + assert.ok(mtime > oldTime.getTime(), 'mtime should be newer than the pre-set old time'); + }); + + it('creates evolver_loop.log when it does not exist on successful heartbeat', async () => { + var logPath = path.join(tmpDir, 'evolver_loop.log'); + if (fs.existsSync(logPath)) fs.unlinkSync(logPath); + + global.fetch = async () => ({ + json: async () => ({ status: 'ok' }), + }); + + var result = await sendHeartbeat(); + assert.ok(result.ok, 'heartbeat should succeed'); + assert.ok(fs.existsSync(logPath), 'evolver_loop.log should be created when missing'); + }); +}); diff --git a/test/contentHash.test.js b/test/contentHash.test.js new file mode 100644 index 0000000..b735ff6 --- /dev/null +++ b/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/test/envFingerprint.test.js b/test/envFingerprint.test.js new file mode 100644 index 0000000..56d8b85 --- /dev/null +++ b/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/test/fixtures/session_mock.jsonl b/test/fixtures/session_mock.jsonl deleted file mode 100644 index 07230f7..0000000 --- a/test/fixtures/session_mock.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"type":"message","message":{"role":"user","content":"Check the current system status and fix any errors."}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"I will inspect the system logs and identify any issues."}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"exec","input":{"command":"node -e \"console.log('health check');\""}}]}} -{"type":"tool_result","tool_result":{"output":"health check\n"}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"System health check passed. Now scanning for errors in recent logs."}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"read","input":{"path":"memory/2026-02-07.md"}}]}} -{"type":"tool_result","tool_result":{"output":"[ERROR] Module initialization failed: Cannot read property 'config' of undefined\nStack: TypeError at startup.js:42\n[ERROR] Retry attempt 2 failed with ECONNREFUSED\nOperation completed with 2 errors."}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Found 2 errors in today's log. The first is a TypeError in startup.js, the second is a connection error. I will apply a targeted fix."}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"edit","input":{"path":"src/startup.js","old":"const cfg = opts.config;","new":"const cfg = opts && opts.config ? opts.config : {};"}}]}} -{"type":"tool_result","tool_result":{"output":"success"}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Applied defensive null check to startup.js. The TypeError should no longer occur."}]}} -{"type":"message","message":{"role":"user","content":"Good. Now optimize the evolution prompt for better signal extraction."}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"I will review the current prompt assembly and look for signal extraction improvements."}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"exec","input":{"command":"node index.js run"}}]}} -{"type":"tool_result","tool_result":{"output":"Scanning session logs...\n[MemoryGraph] Signal snapshot recorded.\nGEP prompt generated. Signals: log_error, protocol_drift\n"}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Evolution prompt generated with signals: log_error, protocol_drift. The GEP protocol is functioning correctly."}]}} -{"type":"message","message":{"role":"user","content":"HEARTBEAT_OK"}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"NO_REPLY"}]}} -{"type":"message","message":{"role":"user","content":"Run solidify to lock in the changes."}} -{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"exec","input":{"command":"node index.js solidify --intent=repair --summary=\"Fix TypeError in startup.js\""}}]}} -{"type":"tool_result","tool_result":{"output":"[SOLIDIFY] SUCCESS\n{\"type\":\"EvolutionEvent\",\"id\":\"evt_1770400000000\",\"outcome\":{\"status\":\"success\",\"score\":0.85}}"}} diff --git a/test/llm_helper.js b/test/llm_helper.js deleted file mode 100644 index 438812b..0000000 --- a/test/llm_helper.js +++ /dev/null @@ -1,123 +0,0 @@ -// Zero-dependency Gemini REST API wrapper for vibe testing. -// Uses only Node.js built-in https module. - -'use strict'; - -var https = require('https'); - -var GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash'; -var GEMINI_ENDPOINT = 'generativelanguage.googleapis.com'; -var GEMINI_TIMEOUT_MS = parseInt(process.env.GEMINI_TIMEOUT_MS || '30000', 10) || 30000; - -function getApiKey() { - return process.env.GEMINI_API_KEY || ''; -} - -function hasApiKey() { - return getApiKey().length > 0; -} - -// Call Gemini generateContent and return the text response. -// Returns a Promise. -function callGemini(prompt) { - var apiKey = getApiKey(); - if (!apiKey) return Promise.reject(new Error('GEMINI_API_KEY not set')); - - var body = JSON.stringify({ - contents: [{ parts: [{ text: String(prompt) }] }], - generationConfig: { - temperature: 0.2, - maxOutputTokens: 4096, - }, - }); - - var options = { - hostname: GEMINI_ENDPOINT, - path: '/v1beta/models/' + GEMINI_MODEL + ':generateContent', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-goog-api-key': apiKey, - 'Content-Length': Buffer.byteLength(body), - }, - timeout: GEMINI_TIMEOUT_MS, - }; - - return new Promise(function (resolve, reject) { - var req = https.request(options, function (res) { - var chunks = []; - res.on('data', function (chunk) { chunks.push(chunk); }); - res.on('end', function () { - var raw = Buffer.concat(chunks).toString('utf8'); - if (res.statusCode < 200 || res.statusCode >= 300) { - return reject(new Error('Gemini API error ' + res.statusCode + ': ' + raw.slice(0, 500))); - } - try { - var json = JSON.parse(raw); - var text = ''; - if (json.candidates && json.candidates[0] && json.candidates[0].content) { - var parts = json.candidates[0].content.parts || []; - for (var i = 0; i < parts.length; i++) { - if (parts[i].text) text += parts[i].text; - } - } - resolve(text); - } catch (e) { - reject(new Error('Gemini response parse error: ' + e.message)); - } - }); - }); - - req.on('error', function (e) { reject(e); }); - req.on('timeout', function () { - req.destroy(); - reject(new Error('Gemini API timeout after ' + GEMINI_TIMEOUT_MS + 'ms')); - }); - - req.write(body); - req.end(); - }); -} - -// Extract JSON objects from LLM response text. -// Looks for lines containing { ... } patterns and tries to parse them. -function extractJsonObjects(text) { - var results = []; - var lines = String(text || '').split('\n'); - var buffer = ''; - var depth = 0; - - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - for (var j = 0; j < line.length; j++) { - var ch = line[j]; - 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') results.push(obj); - } catch (e) {} - buffer = ''; - } - if (depth < 0) depth = 0; - } else if (depth > 0) { - buffer += ch; - } - } - if (depth > 0) buffer += '\n'; - } - - return results; -} - -module.exports = { - callGemini: callGemini, - hasApiKey: hasApiKey, - extractJsonObjects: extractJsonObjects, -}; diff --git a/test/loopMode.test.js b/test/loopMode.test.js new file mode 100644 index 0000000..293b38d --- /dev/null +++ b/test/loopMode.test.js @@ -0,0 +1,70 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { rejectPendingRun } = require('../index.js'); + +describe('loop-mode auto reject', () => { + var tmpDir; + var originalRepoRoot; + var originalWorkspaceRoot; + var originalEvDir; + var originalMemoryDir; + var originalA2aHubUrl; + var originalHeartbeatMs; + var originalWorkerEnabled; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-loop-test-')); + originalRepoRoot = process.env.EVOLVER_REPO_ROOT; + originalWorkspaceRoot = process.env.OPENCLAW_WORKSPACE; + originalEvDir = process.env.EVOLUTION_DIR; + originalMemoryDir = process.env.MEMORY_DIR; + originalA2aHubUrl = process.env.A2A_HUB_URL; + originalHeartbeatMs = process.env.HEARTBEAT_INTERVAL_MS; + originalWorkerEnabled = process.env.WORKER_ENABLED; + process.env.EVOLVER_REPO_ROOT = tmpDir; + process.env.OPENCLAW_WORKSPACE = tmpDir; + process.env.EVOLUTION_DIR = path.join(tmpDir, 'memory', 'evolution'); + process.env.MEMORY_DIR = path.join(tmpDir, 'memory'); + process.env.A2A_HUB_URL = ''; + process.env.HEARTBEAT_INTERVAL_MS = '3600000'; + delete process.env.WORKER_ENABLED; + }); + + afterEach(() => { + if (originalRepoRoot === undefined) delete process.env.EVOLVER_REPO_ROOT; + else process.env.EVOLVER_REPO_ROOT = originalRepoRoot; + if (originalWorkspaceRoot === undefined) delete process.env.OPENCLAW_WORKSPACE; + else process.env.OPENCLAW_WORKSPACE = originalWorkspaceRoot; + if (originalEvDir === undefined) delete process.env.EVOLUTION_DIR; + else process.env.EVOLUTION_DIR = originalEvDir; + if (originalMemoryDir === undefined) delete process.env.MEMORY_DIR; + else process.env.MEMORY_DIR = originalMemoryDir; + if (originalA2aHubUrl === undefined) delete process.env.A2A_HUB_URL; + else process.env.A2A_HUB_URL = originalA2aHubUrl; + if (originalHeartbeatMs === undefined) delete process.env.HEARTBEAT_INTERVAL_MS; + else process.env.HEARTBEAT_INTERVAL_MS = originalHeartbeatMs; + if (originalWorkerEnabled === undefined) delete process.env.WORKER_ENABLED; + else process.env.WORKER_ENABLED = originalWorkerEnabled; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('marks pending runs rejected without deleting untracked files', () => { + const stateDir = path.join(tmpDir, 'memory', 'evolution'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'evolution_solidify_state.json'), JSON.stringify({ + last_run: { run_id: 'run_123' } + }, null, 2)); + fs.writeFileSync(path.join(tmpDir, 'PR_BODY.md'), 'keep me\n'); + const changed = rejectPendingRun(path.join(stateDir, 'evolution_solidify_state.json')); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'evolution_solidify_state.json'), 'utf8')); + assert.equal(changed, true); + assert.equal(state.last_solidify.run_id, 'run_123'); + assert.equal(state.last_solidify.rejected, true); + assert.equal(state.last_solidify.reason, 'loop_bridge_disabled_autoreject_no_rollback'); + assert.equal(fs.readFileSync(path.join(tmpDir, 'PR_BODY.md'), 'utf8'), 'keep me\n'); + }); +}); diff --git a/test/mutation.test.js b/test/mutation.test.js new file mode 100644 index 0000000..f8dc46f --- /dev/null +++ b/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/test/sanitize.test.js b/test/sanitize.test.js new file mode 100644 index 0000000..7df23ed --- /dev/null +++ b/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/test/selector.test.js b/test/selector.test.js new file mode 100644 index 0000000..3664eba --- /dev/null +++ b/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/test/signals.test.js b/test/signals.test.js new file mode 100644 index 0000000..4d77b77 --- /dev/null +++ b/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/test/skillDistiller.test.js b/test/skillDistiller.test.js new file mode 100644 index 0000000..1b7e6d1 --- /dev/null +++ b/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/test/strategy.test.js b/test/strategy.test.js new file mode 100644 index 0000000..ccb172e --- /dev/null +++ b/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/test/validationReport.test.js b/test/validationReport.test.js new file mode 100644 index 0000000..4ad15b1 --- /dev/null +++ b/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/test/vibe_test.js b/test/vibe_test.js deleted file mode 100644 index 78a9b15..0000000 --- a/test/vibe_test.js +++ /dev/null @@ -1,932 +0,0 @@ -#!/usr/bin/env node -// Vibe Testing Framework for Capability Evolver -// Zero-dependency, end-to-end verification in OpenClaw-compatible container. -// Exit code 0 = all pass, 1 = at least one failure. - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// --------------------------------------------------------------------------- -// Harness -// --------------------------------------------------------------------------- - -const results = []; -let currentTest = null; - -function pad(s, n) { - var str = String(s); - while (str.length < n) str += '.'; - return str; -} - -function run(id, name, fn) { - currentTest = { id: id, name: name }; - var t0 = Date.now(); - try { - fn(); - var dt = Date.now() - t0; - results.push({ id: id, name: name, ok: true, dt: dt, error: null }); - process.stdout.write('[VIBE] ' + id + ' ' + pad(name + ' ', 30) + ' PASS (' + dt + 'ms)\n'); - } catch (e) { - var dt2 = Date.now() - t0; - var msg = e && e.message ? e.message : String(e); - results.push({ id: id, name: name, ok: false, dt: dt2, error: msg }); - process.stdout.write('[VIBE] ' + id + ' ' + pad(name + ' ', 30) + ' FAIL (' + dt2 + 'ms)\n'); - process.stdout.write(' -> ' + msg + '\n'); - } - currentTest = null; -} - -function assert(cond, msg) { - if (!cond) throw new Error(msg || 'assertion failed'); -} - -function assertType(obj, field, expected, label) { - var actual = typeof obj[field]; - assert(actual === expected, (label || field) + ': expected typeof ' + expected + ', got ' + actual); -} - -// --------------------------------------------------------------------------- -// Resolve skill root (works both in-repo and in Docker container) -// --------------------------------------------------------------------------- - -var SKILL_ROOT = process.env.SKILL_ROOT || path.resolve(__dirname, '..'); - -// --------------------------------------------------------------------------- -// T1: Module Load -// --------------------------------------------------------------------------- - -run('T1', 'module_load', function () { - var modules = [ - 'src/gep/contentHash', - 'src/gep/envFingerprint', - 'src/gep/validationReport', - 'src/gep/a2aProtocol', - 'src/gep/paths', - 'src/gep/signals', - 'src/gep/selector', - 'src/gep/assetStore', - 'src/gep/mutation', - 'src/gep/personality', - 'src/gep/memoryGraph', - 'src/gep/a2a', - 'src/gep/candidates', - 'src/gep/bridge', - 'src/gep/prompt', - 'src/gep/solidify', - 'src/evolve', - ]; - for (var i = 0; i < modules.length; i++) { - var mod = modules[i]; - try { - require(path.join(SKILL_ROOT, mod)); - } catch (e) { - throw new Error('Failed to load ' + mod + ': ' + (e.message || e)); - } - } - assert(modules.length >= 15, 'Expected at least 15 modules, got ' + modules.length); -}); - -// --------------------------------------------------------------------------- -// T2: Dry-Run Solidify -// --------------------------------------------------------------------------- - -run('T2', 'dry_run_solidify', function () { - var solidify = require(path.join(SKILL_ROOT, 'src/gep/solidify')).solidify; - var res = solidify({ dryRun: true }); - - assert(res && typeof res === 'object', 'solidify should return an object'); - assert(res.event && typeof res.event === 'object', 'result should contain event'); - assert(typeof res.event.id === 'string', 'event.id should be a string'); - assert(typeof res.event.type === 'string', 'event.type should be a string'); - assert(res.event.type === 'EvolutionEvent', 'event.type should be EvolutionEvent'); - assert(res.gene && typeof res.gene === 'object', 'result should contain gene'); - assert(res.validationReport && typeof res.validationReport === 'object', 'result should contain validationReport'); - assert(res.validationReport.type === 'ValidationReport', 'validationReport.type should be ValidationReport'); -}); - -// --------------------------------------------------------------------------- -// T3: Schema Compliance -// --------------------------------------------------------------------------- - -run('T3', 'schema_compliance', function () { - var solidify = require(path.join(SKILL_ROOT, 'src/gep/solidify')).solidify; - var res = solidify({ dryRun: true }); - - // EvolutionEvent - var ev = res.event; - assert(typeof ev.schema_version === 'string' && ev.schema_version.length > 0, 'event missing schema_version'); - assert(typeof ev.asset_id === 'string' && ev.asset_id.startsWith('sha256:'), 'event missing valid asset_id'); - assert(ev.env_fingerprint && typeof ev.env_fingerprint === 'object', 'event missing env_fingerprint'); - assert(typeof ev.env_fingerprint.node_version === 'string', 'event.env_fingerprint missing node_version'); - assert(typeof ev.env_fingerprint.platform === 'string', 'event.env_fingerprint missing platform'); - assert(typeof ev.validation_report_id === 'string', 'event missing validation_report_id'); - - // Gene - var gene = res.gene; - assert(typeof gene.schema_version === 'string', 'gene missing schema_version'); - assert(typeof gene.asset_id === 'string' && gene.asset_id.startsWith('sha256:'), 'gene missing valid asset_id'); - - // ValidationReport - var vr = res.validationReport; - assert(typeof vr.schema_version === 'string', 'validationReport missing schema_version'); - assert(typeof vr.asset_id === 'string' && vr.asset_id.startsWith('sha256:'), 'validationReport missing valid asset_id'); - assert(vr.env_fingerprint && typeof vr.env_fingerprint === 'object', 'validationReport missing env_fingerprint'); - assert(typeof vr.env_fingerprint_key === 'string' && vr.env_fingerprint_key.length > 0, 'validationReport missing env_fingerprint_key'); - assert(typeof vr.overall_ok === 'boolean', 'validationReport missing overall_ok'); - assert(Array.isArray(vr.commands), 'validationReport missing commands array'); - - // Schema versions should all match - assert(ev.schema_version === gene.schema_version, 'schema_version mismatch between event and gene'); - assert(ev.schema_version === vr.schema_version, 'schema_version mismatch between event and validationReport'); -}); - -// --------------------------------------------------------------------------- -// T4: A2A Round-Trip -// --------------------------------------------------------------------------- - -run('T4', 'a2a_round_trip', function () { - var contentHash = require(path.join(SKILL_ROOT, 'src/gep/contentHash')); - var a2aProto = require(path.join(SKILL_ROOT, 'src/gep/a2aProtocol')); - var a2a = require(path.join(SKILL_ROOT, 'src/gep/a2a')); - - // Create a test capsule with asset_id - var capsule = { - type: 'Capsule', - schema_version: contentHash.SCHEMA_VERSION, - id: 'capsule_vibe_test_1', - trigger: ['log_error'], - gene: 'gene_vibe_test', - summary: 'Vibe test capsule for A2A round-trip', - confidence: 0.8, - blast_radius: { files: 1, lines: 10 }, - outcome: { status: 'success', score: 0.85 }, - success_streak: 2, - a2a: { eligible_to_broadcast: true }, - }; - capsule.asset_id = contentHash.computeAssetId(capsule); - - // Step 1: Wrap in publish protocol message - var publishMsg = a2aProto.buildPublish({ asset: capsule }); - assert(publishMsg.protocol === 'gep-a2a', 'publish message should have gep-a2a protocol'); - assert(publishMsg.message_type === 'publish', 'message_type should be publish'); - assert(publishMsg.payload.asset_id === capsule.asset_id, 'publish payload should carry asset_id'); - - // Step 2: Serialize and parse (simulate network transfer) - var wire = JSON.stringify(publishMsg); - var received = JSON.parse(wire); - - // Step 3: Unwrap using A2A protocol - var unwrapped = a2aProto.unwrapAssetFromMessage(received); - assert(unwrapped !== null, 'unwrapAssetFromMessage should extract asset'); - assert(unwrapped.type === 'Capsule', 'unwrapped asset should be a Capsule'); - assert(unwrapped.id === capsule.id, 'unwrapped asset id should match'); - - // Step 4: Verify asset_id integrity - var verified = contentHash.verifyAssetId(unwrapped); - assert(verified === true, 'asset_id integrity check should pass after round-trip'); - - // Step 5: parseA2AInput should also handle protocol messages - var parsed = a2a.parseA2AInput(wire); - assert(Array.isArray(parsed) && parsed.length === 1, 'parseA2AInput should return 1 asset from protocol message'); - assert(parsed[0].type === 'Capsule', 'parsed asset should be a Capsule'); - - // Step 6: Tamper detection -- mutate and verify fails - var tampered = JSON.parse(JSON.stringify(unwrapped)); - tampered.confidence = 0.99; - var tamperedVerify = contentHash.verifyAssetId(tampered); - assert(tamperedVerify === false, 'tampered asset should fail integrity check'); - - // Step 7: Verify hello message format - var hello = a2aProto.buildHello({ geneCount: 2, capsuleCount: 1 }); - assert(hello.protocol === 'gep-a2a', 'hello should have gep-a2a protocol'); - assert(hello.message_type === 'hello', 'hello message_type should be hello'); - assert(hello.payload.env_fingerprint && typeof hello.payload.env_fingerprint === 'object', 'hello should contain env_fingerprint'); - - // Step 8: Verify decision message format - var decision = a2aProto.buildDecision({ assetId: capsule.asset_id, decision: 'accept', reason: 'vibe test' }); - assert(decision.message_type === 'decision', 'decision message_type should be decision'); - assert(decision.payload.decision === 'accept', 'decision payload should be accept'); -}); - -// --------------------------------------------------------------------------- -// T4b: Innovation Signal Detection -// --------------------------------------------------------------------------- - -run('T4b', 'innovation_signal', function () { - var signals = require(path.join(SKILL_ROOT, 'src/gep/signals')); - var mutation = require(path.join(SKILL_ROOT, 'src/gep/mutation')); - - // Test 1: user_feature_request detection - var res1 = signals.extractSignals({ - recentSessionTranscript: 'The user said: please add a new notification module for the agent.', - todayLog: '', - memorySnippet: '', - userSnippet: '', - }); - assert(res1.includes('user_feature_request'), 'should detect user_feature_request from "please add ... module"'); - - // Test 2: "I want X" pattern - var res2 = signals.extractSignals({ - recentSessionTranscript: 'I want a dashboard that shows evolution history.', - todayLog: '', - memorySnippet: '', - userSnippet: '', - }); - assert(res2.includes('user_feature_request'), 'should detect user_feature_request from "I want ..."'); - - // Test 3: perf_bottleneck detection - var res3 = signals.extractSignals({ - recentSessionTranscript: 'The scan took too long, over 30 seconds of latency.', - todayLog: '', - memorySnippet: '', - userSnippet: '', - }); - assert(res3.includes('perf_bottleneck'), 'should detect perf_bottleneck from "took too long" and "latency"'); - - // Test 4: capability_gap detection - var res4 = signals.extractSignals({ - recentSessionTranscript: 'HTTP transport is not supported yet for A2A.', - todayLog: '', - memorySnippet: '', - userSnippet: '', - }); - assert(res4.includes('capability_gap'), 'should detect capability_gap from "not supported"'); - - // Test 5: user_improvement_suggestion (without error) - var res5 = signals.extractSignals({ - recentSessionTranscript: 'The prompt assembly could be better and should be simplified.', - todayLog: '', - memorySnippet: '', - userSnippet: '', - }); - assert(res5.includes('user_improvement_suggestion'), 'should detect user_improvement_suggestion from "could be better"'); - - // Test 6: mutation category should be innovate when opportunity signal present - var cat1 = mutation.buildMutation({ signals: ['user_feature_request'], driftEnabled: false }); - assert(cat1.category === 'innovate', 'mutation category should be innovate for user_feature_request, got: ' + cat1.category); - - var cat2 = mutation.buildMutation({ signals: ['perf_bottleneck'], driftEnabled: false }); - assert(cat2.category === 'innovate', 'mutation category should be innovate for perf_bottleneck, got: ' + cat2.category); - - // Test 7: repair still takes priority over innovate - var cat3 = mutation.buildMutation({ signals: ['log_error', 'user_feature_request'], driftEnabled: false }); - assert(cat3.category === 'repair', 'mutation category should be repair when log_error is present even with opportunity signal, got: ' + cat3.category); - - // Test 8: no signals -> optimize (not innovate) - var cat4 = mutation.buildMutation({ signals: [], driftEnabled: false }); - assert(cat4.category === 'optimize', 'mutation category should be optimize with no signals, got: ' + cat4.category); - - // Test 9: hasOpportunitySignal utility - assert(mutation.hasOpportunitySignal(['user_feature_request']) === true, 'hasOpportunitySignal should return true'); - assert(mutation.hasOpportunitySignal(['log_error']) === false, 'hasOpportunitySignal should return false for error signals'); - assert(mutation.hasOpportunitySignal([]) === false, 'hasOpportunitySignal should return false for empty signals'); -}); - -// --------------------------------------------------------------------------- -// T5: Full evolve + solidify -// --------------------------------------------------------------------------- - -run('T5', 'full_evolve_solidify', function () { - var evolve = require(path.join(SKILL_ROOT, 'src/evolve')); - var solidifyMod = require(path.join(SKILL_ROOT, 'src/gep/solidify')); - var assetStore = require(path.join(SKILL_ROOT, 'src/gep/assetStore')); - - // Count events before - var eventsBefore = assetStore.readAllEvents().length; - - // evolve.run() is async but we need it synchronous-ish for the test. - // We can call it and catch; the key thing is it should not throw. - var runOk = false; - var runError = null; - - // Run evolve synchronously by forcing bridge off and capturing output - process.env.EVOLVE_BRIDGE = 'false'; - process.env.EVOLVE_PRINT_PROMPT = 'false'; - - // evolve.run() returns a promise. We handle it in a blocking-ish way - // by writing a sync wrapper using spawnSync on ourselves. - var spawnSync = require('child_process').spawnSync; - var scriptContent = [ - 'process.env.EVOLVE_BRIDGE = "false";', - 'process.env.EVOLVE_PRINT_PROMPT = "false";', - 'var evolve = require("' + path.join(SKILL_ROOT, 'src/evolve').replace(/\\/g, '\\\\') + '");', - 'evolve.run().then(function() {', - ' process.exit(0);', - '}).catch(function(e) {', - ' process.stderr.write((e && e.message ? e.message : String(e)) + "\\n");', - ' process.exit(1);', - '});', - ].join('\n'); - - var evolveResult = spawnSync(process.execPath, ['-e', scriptContent], { - cwd: SKILL_ROOT, - encoding: 'utf8', - timeout: 30000, - env: Object.assign({}, process.env, { - EVOLVE_BRIDGE: 'false', - EVOLVE_PRINT_PROMPT: 'false', - }), - }); - - assert(evolveResult.status === 0, 'evolve.run() should exit 0, got ' + evolveResult.status + ': ' + (evolveResult.stderr || '').slice(0, 300)); - - // Now run solidify - var res = solidifyMod.solidify({ dryRun: false, rollbackOnFailure: false }); - assert(res && typeof res === 'object', 'solidify should return an object'); - assert(res.event && res.event.type === 'EvolutionEvent', 'solidify should produce an EvolutionEvent'); - - // Verify events.jsonl was written - var eventsAfter = assetStore.readAllEvents().length; - assert(eventsAfter > eventsBefore, 'events.jsonl should have more entries after solidify (before=' + eventsBefore + ', after=' + eventsAfter + ')'); - - // Verify the event has all new fields - var lastEvents = assetStore.readAllEvents(); - var lastEvt = null; - for (var i = lastEvents.length - 1; i >= 0; i--) { - if (lastEvents[i] && lastEvents[i].type === 'EvolutionEvent') { - lastEvt = lastEvents[i]; - break; - } - } - assert(lastEvt !== null, 'should find an EvolutionEvent in events.jsonl'); - assert(typeof lastEvt.schema_version === 'string', 'persisted event should have schema_version'); - assert(typeof lastEvt.asset_id === 'string', 'persisted event should have asset_id'); - assert(lastEvt.env_fingerprint && typeof lastEvt.env_fingerprint === 'object', 'persisted event should have env_fingerprint'); -}); - -// --------------------------------------------------------------------------- -// T6: Loop Gating -// --------------------------------------------------------------------------- - -run('T6', 'loop_gating', function () { - var solidifyMod = require(path.join(SKILL_ROOT, 'src/gep/solidify')); - - // Read current solidify state - var state = solidifyMod.readStateForSolidify(); - - // Simulate a pending run by setting last_run with a unique run_id - // but no matching last_solidify - var fakeRunId = 'run_vibe_test_' + Date.now(); - state.last_run = { - run_id: fakeRunId, - created_at: new Date().toISOString(), - signals: ['vibe_test'], - }; - // Clear last_solidify to create a "pending" state - var savedSolidify = state.last_solidify; - state.last_solidify = null; - - solidifyMod.writeStateForSolidify(state); - - // Verify isPendingSolidify logic - var stateReread = solidifyMod.readStateForSolidify(); - var lastRun = stateReread.last_run; - var lastSolid = stateReread.last_solidify; - var isPending = lastRun && lastRun.run_id && (!lastSolid || !lastSolid.run_id || String(lastSolid.run_id) !== String(lastRun.run_id)); - assert(isPending === true, 'State should be pending solidify when run_id does not match'); - - // Simulate solidify completing by setting matching run_id - state.last_solidify = { run_id: fakeRunId, at: new Date().toISOString() }; - solidifyMod.writeStateForSolidify(state); - - stateReread = solidifyMod.readStateForSolidify(); - lastRun = stateReread.last_run; - lastSolid = stateReread.last_solidify; - isPending = lastRun && lastRun.run_id && (!lastSolid || !lastSolid.run_id || String(lastSolid.run_id) !== String(lastRun.run_id)); - assert(isPending === false, 'State should NOT be pending after solidify completes'); - - // Restore original state - if (savedSolidify) { - state.last_solidify = savedSolidify; - solidifyMod.writeStateForSolidify(state); - } -}); - -// --------------------------------------------------------------------------- -// T7: Env Fingerprint (container isolation) -// --------------------------------------------------------------------------- - -run('T7', 'env_fingerprint', function () { - var envFp = require(path.join(SKILL_ROOT, 'src/gep/envFingerprint')); - var contentHash = require(path.join(SKILL_ROOT, 'src/gep/contentHash')); - - var fp = envFp.captureEnvFingerprint(); - - // Basic structure - assert(typeof fp.node_version === 'string' && fp.node_version.length > 0, 'fingerprint should have node_version'); - assert(typeof fp.platform === 'string' && fp.platform.length > 0, 'fingerprint should have platform'); - assert(typeof fp.arch === 'string' && fp.arch.length > 0, 'fingerprint should have arch'); - assert(typeof fp.captured_at === 'string', 'fingerprint should have captured_at'); - - // Key generation - var key = envFp.envFingerprintKey(fp); - assert(typeof key === 'string' && key.length === 16, 'fingerprint key should be 16-char hex, got: ' + key); - - // Same env should produce same key - var fp2 = envFp.captureEnvFingerprint(); - var key2 = envFp.envFingerprintKey(fp2); - assert(key === key2, 'same environment should produce same fingerprint key'); - - // Different env should produce different key - var fakeRemoteFp = { - node_version: 'v18.0.0', - platform: 'darwin', - arch: 'arm64', - evolver_version: '0.9.0', - }; - var remoteKey = envFp.envFingerprintKey(fakeRemoteFp); - assert(remoteKey !== key, 'different environment should produce different fingerprint key'); - - // isSameEnvClass - assert(envFp.isSameEnvClass(fp, fp2) === true, 'same env should be same class'); - assert(envFp.isSameEnvClass(fp, fakeRemoteFp) === false, 'different env should be different class'); - - // In Docker container, platform should be linux - if (process.env.NODE_ENV === 'test' && fp.platform === 'linux') { - process.stdout.write(' (confirmed: running in Linux container)\n'); - } -}); - -// --------------------------------------------------------------------------- -// T8: Personality Evolution -// --------------------------------------------------------------------------- - -run('T8', 'personality_evolution', function () { - var personality = require(path.join(SKILL_ROOT, 'src/gep/personality')); - - // Select personality with opportunity signal - var sel = personality.selectPersonalityForRun({ - driftEnabled: false, - signals: ['user_feature_request'], - recentEvents: [], - }); - - assert(sel && sel.personality_state, 'selectPersonalityForRun should return personality_state'); - assert(sel.personality_state.type === 'PersonalityState', 'should be a PersonalityState'); - assert(typeof sel.personality_key === 'string', 'should have personality_key'); - - // Update stats with success - var statResult = personality.updatePersonalityStats({ - personalityState: sel.personality_state, - outcome: 'success', - score: 0.9, - notes: 'vibe_test_T8', - }); - assert(statResult && statResult.key, 'updatePersonalityStats should return key'); - assert(statResult.stats && typeof statResult.stats.success === 'number', 'stats should have success count'); - - // Load model and verify stats persisted - var model = personality.loadPersonalityModel(); - assert(model && model.stats && typeof model.stats === 'object', 'model should have stats'); - assert(Array.isArray(model.history) && model.history.length > 0, 'model should have history'); -}); - -// --------------------------------------------------------------------------- -// T9: Memory Graph Causal Chain -// --------------------------------------------------------------------------- - -run('T9', 'memory_graph_causal', function () { - var mg = require(path.join(SKILL_ROOT, 'src/gep/memoryGraph')); - var assetStore = require(path.join(SKILL_ROOT, 'src/gep/assetStore')); - var fs2 = require('fs'); - - var testSignals = ['log_error', 'errsig:TypeError at test.js:1']; - var testObs = { agent: 'vibe_test', node: process.version }; - - // Record signal snapshot - var sigEvt = mg.recordSignalSnapshot({ signals: testSignals, observations: testObs }); - assert(sigEvt && sigEvt.type === 'MemoryGraphEvent', 'should return MemoryGraphEvent'); - assert(sigEvt.kind === 'signal', 'kind should be signal'); - - // Record hypothesis - var hyp = mg.recordHypothesis({ - signals: testSignals, - mutation: null, - personality_state: null, - selectedGene: { id: 'gene_gep_repair_from_errors', category: 'repair' }, - selector: { selected: 'gene_gep_repair_from_errors', reason: ['test'] }, - driftEnabled: false, - selectedBy: 'selector', - capsulesUsed: [], - observations: testObs, - }); - assert(hyp && hyp.hypothesisId, 'should return hypothesisId'); - - // Record attempt - var att = mg.recordAttempt({ - signals: testSignals, - mutation: null, - personality_state: null, - selectedGene: { id: 'gene_gep_repair_from_errors', category: 'repair' }, - selector: { selected: 'gene_gep_repair_from_errors', reason: ['test'] }, - driftEnabled: false, - selectedBy: 'selector', - hypothesisId: hyp.hypothesisId, - capsulesUsed: [], - observations: testObs, - }); - assert(att && att.actionId, 'should return actionId'); - - // Record outcome - var out = mg.recordOutcomeFromState({ signals: [], observations: { agent: 'vibe_test_after' } }); - assert(out && out.type === 'MemoryGraphEvent', 'outcome should be MemoryGraphEvent'); - assert(out.kind === 'outcome', 'outcome kind should be outcome'); - - // Read graph and verify all 4 kinds present - var events = mg.tryReadMemoryGraphEvents(500); - var kinds = {}; - for (var i = 0; i < events.length; i++) { - if (events[i] && events[i].kind) kinds[events[i].kind] = true; - } - assert(kinds.signal, 'graph should contain signal events'); - assert(kinds.hypothesis, 'graph should contain hypothesis events'); - assert(kinds.attempt, 'graph should contain attempt events'); - assert(kinds.outcome, 'graph should contain outcome events'); - - // Get memory advice - var genes = assetStore.loadGenes(); - var advice = mg.getMemoryAdvice({ signals: testSignals, genes: genes, driftEnabled: false }); - assert(advice && typeof advice === 'object', 'getMemoryAdvice should return object'); - assert(typeof advice.currentSignalKey === 'string', 'should have currentSignalKey'); -}); - -// --------------------------------------------------------------------------- -// T10: A2A Ingest + Promote E2E -// --------------------------------------------------------------------------- - -run('T10', 'a2a_ingest_promote', function () { - var spawnSync = require('child_process').spawnSync; - var contentHash = require(path.join(SKILL_ROOT, 'src/gep/contentHash')); - var assetStore = require(path.join(SKILL_ROOT, 'src/gep/assetStore')); - - // Create a test capsule with asset_id - var testCap = { - type: 'Capsule', - schema_version: contentHash.SCHEMA_VERSION, - id: 'capsule_vibe_t10_' + Date.now(), - trigger: ['vibe_test'], - gene: 'gene_test', - summary: 'Vibe T10 test capsule', - confidence: 0.75, - blast_radius: { files: 1, lines: 5 }, - outcome: { status: 'success', score: 0.8 }, - success_streak: 1, - a2a: { eligible_to_broadcast: true }, - }; - testCap.asset_id = contentHash.computeAssetId(testCap); - - // Ingest via script (pipe JSON to stdin) - var ingestResult = spawnSync(process.execPath, [path.join(SKILL_ROOT, 'scripts/a2a_ingest.js')], { - input: JSON.stringify(testCap), - cwd: SKILL_ROOT, - encoding: 'utf8', - timeout: 10000, - }); - assert(ingestResult.status === 0, 'a2a_ingest should exit 0, got ' + ingestResult.status + ': ' + (ingestResult.stderr || '').slice(0, 200)); - assert(String(ingestResult.stdout || '').includes('accepted=1'), 'should accept 1 asset'); - - // Promote via script - var promoteResult = spawnSync(process.execPath, [ - path.join(SKILL_ROOT, 'scripts/a2a_promote.js'), - '--type', 'capsule', - '--id', testCap.id, - '--validated', - ], { - cwd: SKILL_ROOT, - encoding: 'utf8', - timeout: 10000, - }); - assert(promoteResult.status === 0, 'a2a_promote should exit 0, got ' + promoteResult.status + ': ' + (promoteResult.stderr || '').slice(0, 200)); - assert(String(promoteResult.stdout || '').includes('promoted_capsule='), 'should confirm promotion'); - - // Verify capsule in store - var capsules = assetStore.loadCapsules(); - var found = false; - for (var i = 0; i < capsules.length; i++) { - if (capsules[i] && capsules[i].id === testCap.id) { found = true; break; } - } - assert(found, 'promoted capsule should appear in capsules.json'); -}); - -// --------------------------------------------------------------------------- -// T11: Selector Gene Match -// --------------------------------------------------------------------------- - -run('T11', 'selector_gene_match', function () { - var selector = require(path.join(SKILL_ROOT, 'src/gep/selector')); - var assetStore = require(path.join(SKILL_ROOT, 'src/gep/assetStore')); - - var genes = assetStore.loadGenes(); - var capsules = assetStore.loadCapsules(); - - // log_error should select repair gene - var r1 = selector.selectGeneAndCapsule({ - genes: genes, capsules: capsules, signals: ['log_error', 'error'], - memoryAdvice: null, driftEnabled: false, - }); - assert(r1.selectedGene && r1.selectedGene.category === 'repair', - 'log_error should select repair gene, got: ' + (r1.selectedGene ? r1.selectedGene.category : 'null')); - - // user_feature_request should select innovate gene - var r2 = selector.selectGeneAndCapsule({ - genes: genes, capsules: capsules, signals: ['user_feature_request'], - memoryAdvice: null, driftEnabled: false, - }); - assert(r2.selectedGene && r2.selectedGene.category === 'innovate', - 'user_feature_request should select innovate gene, got: ' + (r2.selectedGene ? r2.selectedGene.category : 'null')); - - // protocol signal should select optimize gene - var r3 = selector.selectGeneAndCapsule({ - genes: genes, capsules: capsules, signals: ['protocol', 'prompt'], - memoryAdvice: null, driftEnabled: false, - }); - assert(r3.selectedGene && r3.selectedGene.category === 'optimize', - 'protocol should select optimize gene, got: ' + (r3.selectedGene ? r3.selectedGene.category : 'null')); -}); - -// --------------------------------------------------------------------------- -// T12: Prompt Structure -// --------------------------------------------------------------------------- - -run('T12', 'prompt_structure', function () { - var buildGepPrompt = require(path.join(SKILL_ROOT, 'src/gep/prompt')).buildGepPrompt; - - var prompt = buildGepPrompt({ - nowIso: new Date().toISOString(), - context: 'Test context for vibe testing.', - signals: ['log_error', 'user_feature_request'], - selector: { selected: 'gene_gep_repair_from_errors', reason: ['test'], alternatives: [] }, - parentEventId: 'evt_test_parent', - selectedGene: { id: 'gene_gep_repair_from_errors', type: 'Gene' }, - capsuleCandidates: [], - genesPreview: '```json\n[]\n```', - capsulesPreview: '```json\n[]\n```', - capabilityCandidatesPreview: '(none)', - externalCandidatesPreview: '(none)', - }); - - assert(typeof prompt === 'string', 'prompt should be a string'); - assert(prompt.length >= 1000, 'prompt should be at least 1000 chars, got ' + prompt.length); - assert(prompt.length <= 40000, 'prompt should be at most 40000 chars, got ' + prompt.length); - - // Check key sections - var sections = ['GEP', 'Mutation', 'PersonalityState', 'EvolutionEvent', 'Gene', 'Capsule']; - for (var i = 0; i < sections.length; i++) { - assert(prompt.includes(sections[i]), 'prompt should contain section: ' + sections[i]); - } - - // Check signals are embedded - assert(prompt.includes('log_error'), 'prompt should contain signal log_error'); - assert(prompt.includes('user_feature_request'), 'prompt should contain signal user_feature_request'); - - // Check selector is embedded - assert(prompt.includes('gene_gep_repair_from_errors'), 'prompt should contain selected gene id'); -}); - -// --------------------------------------------------------------------------- -// Phase 2: LLM-driven tests (require GEMINI_API_KEY) -// --------------------------------------------------------------------------- - -var llmHelper = require(path.join(__dirname, 'llm_helper')); - -// Async test runner for LLM tests -var asyncTests = []; - -function runAsync(id, name, fn) { - if (!llmHelper.hasApiKey()) { - process.stdout.write('[VIBE] ' + id + ' ' + pad(name + ' ', 30) + ' SKIP (no GEMINI_API_KEY)\n'); - results.push({ id: id, name: name, ok: true, dt: 0, error: null, skipped: true }); - return; - } - asyncTests.push({ id: id, name: name, fn: fn }); -} - -// --------------------------------------------------------------------------- -// T13: LLM Prompt Judge -// --------------------------------------------------------------------------- - -runAsync('T13', 'llm_prompt_judge', function () { - var buildGepPrompt = require(path.join(SKILL_ROOT, 'src/gep/prompt')).buildGepPrompt; - - var prompt = buildGepPrompt({ - nowIso: new Date().toISOString(), - context: 'Recent session had 2 errors in startup.js. Agent needs to fix TypeError.', - signals: ['log_error', 'errsig:TypeError at startup.js:42'], - selector: { selected: 'gene_gep_repair_from_errors', reason: ['signals match'], alternatives: [] }, - parentEventId: 'evt_0', - selectedGene: { id: 'gene_gep_repair_from_errors', type: 'Gene', category: 'repair' }, - capsuleCandidates: [], - genesPreview: '```json\n[{"type":"Gene","id":"gene_gep_repair_from_errors","category":"repair"}]\n```', - capsulesPreview: '```json\n[]\n```', - capabilityCandidatesPreview: '(none)', - externalCandidatesPreview: '(none)', - }); - - var judgePrompt = [ - 'You are a protocol compliance judge.', - 'Evaluate the following GEP evolution prompt.', - 'Score 1-10 on each dimension:', - '- protocol_completeness: Does it require all 5 mandatory objects (Mutation, PersonalityState, EvolutionEvent, Gene, Capsule)?', - '- signal_grounding: Are signals extracted and referenced?', - '- safety_constraints: Are blast radius limits and validation steps present?', - '- actionability: Can an executor follow this to produce a patch?', - 'Return JSON only: { "scores": { "protocol_completeness": N, "signal_grounding": N, "safety_constraints": N, "actionability": N }, "overall": N, "issues": ["..."] }', - '', - '--- GEP PROMPT START ---', - prompt.slice(0, 12000), - '--- GEP PROMPT END ---', - ].join('\n'); - - return llmHelper.callGemini(judgePrompt).then(function (response) { - var objs = llmHelper.extractJsonObjects(response); - assert(objs.length >= 1, 'Gemini should return at least 1 JSON object, got: ' + response.slice(0, 300)); - var verdict = objs[0]; - assert(typeof verdict.overall === 'number', 'verdict should have numeric overall score'); - process.stdout.write(' (LLM judge overall=' + verdict.overall + '/10)\n'); - assert(verdict.overall >= 6, 'overall score should be >= 6, got ' + verdict.overall); - }); -}); - -// --------------------------------------------------------------------------- -// T14: LLM Executor Closed Loop -// --------------------------------------------------------------------------- - -runAsync('T14', 'llm_executor_loop', function () { - var buildGepPrompt = require(path.join(SKILL_ROOT, 'src/gep/prompt')).buildGepPrompt; - var solidifyMod = require(path.join(SKILL_ROOT, 'src/gep/solidify')); - - var prompt = buildGepPrompt({ - nowIso: new Date().toISOString(), - context: 'System is stable. No errors detected.', - signals: ['user_feature_request'], - selector: { selected: 'gene_gep_innovate_from_opportunity', reason: ['opportunity signal'], alternatives: [] }, - parentEventId: 'evt_0', - selectedGene: { id: 'gene_gep_innovate_from_opportunity', type: 'Gene', category: 'innovate' }, - capsuleCandidates: [], - genesPreview: '```json\n[]\n```', - capsulesPreview: '```json\n[]\n```', - capabilityCandidatesPreview: '(none)', - externalCandidatesPreview: '(none)', - }); - - var execPrompt = [ - 'You are a GEP executor.', - 'Read the protocol prompt below and produce the mandatory output objects.', - 'You MUST output valid JSON for each object on its own line.', - 'Do NOT write code patches. Only output the protocol objects.', - 'Required objects:', - 'MUTATION: { "type": "Mutation", "id": "mut_", "category": "innovate", "trigger_signals": ["user_feature_request"], "target": "behavior:protocol", "expected_effect": "...", "risk_level": "medium" }', - 'PERSONALITY: { "type": "PersonalityState", "rigor": 0.7, "creativity": 0.5, "verbosity": 0.3, "risk_tolerance": 0.4, "obedience": 0.8 }', - 'EVENT: { "type": "EvolutionEvent", "id": "evt_", "parent": "evt_0", "intent": "innovate", "signals": ["user_feature_request"], "genes_used": ["gene_gep_innovate_from_opportunity"], "mutation_id": "mut_", "blast_radius": {"files": 1, "lines": 10}, "outcome": {"status": "success", "score": 0.8} }', - '', - '--- GEP PROMPT ---', - prompt.slice(0, 10000), - '--- END ---', - ].join('\n'); - - return llmHelper.callGemini(execPrompt).then(function (response) { - var objs = llmHelper.extractJsonObjects(response); - assert(objs.length >= 2, 'Gemini should return at least 2 JSON objects, got ' + objs.length); - - // Find Mutation and EvolutionEvent - var hasMutation = false; - var hasEvent = false; - for (var i = 0; i < objs.length; i++) { - if (objs[i].type === 'Mutation') { - assert(objs[i].id && objs[i].category, 'Mutation should have id and category'); - hasMutation = true; - } - if (objs[i].type === 'EvolutionEvent') { - assert(objs[i].id && objs[i].intent && objs[i].outcome, 'Event should have id, intent, outcome'); - hasEvent = true; - } - } - assert(hasMutation, 'LLM output should contain a Mutation object'); - assert(hasEvent, 'LLM output should contain an EvolutionEvent object'); - process.stdout.write(' (LLM produced ' + objs.length + ' protocol objects)\n'); - - // Verify solidify still works after LLM execution - var res = solidifyMod.solidify({ dryRun: true }); - assert(res && res.event, 'solidify dry-run should still work after LLM test'); - }); -}); - -// --------------------------------------------------------------------------- -// T15: LLM Innovation Proposal -// --------------------------------------------------------------------------- - -runAsync('T15', 'llm_innovation', function () { - var innovatePrompt = [ - 'You are a GEP (Genome Evolution Protocol) innovator agent.', - 'The signals indicate a user wants a new feature: "Add a dashboard that shows evolution history."', - '', - 'You MUST output exactly 2 JSON objects:', - '', - 'Object 1 - A Gene:', - '{"type":"Gene","id":"gene_dashboard_feature","category":"innovate","signals_match":["user_feature_request"],"preconditions":["user requests dashboard"],"strategy":["Design dashboard UI","Implement history view"],"constraints":{"max_files":8,"forbidden_paths":[".git"]},"validation":["node -e \\"console.log(\'ok\')\\""] }', - '', - 'Object 2 - An EvolutionEvent:', - '{"type":"EvolutionEvent","id":"evt_dashboard","parent":null,"intent":"innovate","signals":["user_feature_request"],"genes_used":["gene_dashboard_feature"],"mutation_id":"mut_1","blast_radius":{"files":2,"lines":50},"outcome":{"status":"success","score":0.8}}', - '', - 'Output ONLY valid JSON. Follow the exact field names shown above. Do NOT add explanations.', - ].join('\n'); - - return llmHelper.callGemini(innovatePrompt).then(function (response) { - var objs = llmHelper.extractJsonObjects(response); - assert(objs.length >= 1, 'LLM should return at least 1 JSON object, got 0 from: ' + response.slice(0, 200)); - - var hasInnovateEvent = false; - var hasInnovateGene = false; - for (var i = 0; i < objs.length; i++) { - var o = objs[i]; - // Check for innovate intent/category (flexible matching) - if (o.type === 'EvolutionEvent' && (o.intent === 'innovate' || (o.intent && String(o.intent).includes('innovat')))) hasInnovateEvent = true; - if (o.type === 'Gene' && (o.category === 'innovate' || (o.category && String(o.category).includes('innovat')))) hasInnovateGene = true; - // Also accept if type contains the keywords - if (o.intent === 'innovate' || o.category === 'innovate') { - if (o.type === 'EvolutionEvent') hasInnovateEvent = true; - if (o.type === 'Gene') hasInnovateGene = true; - } - } - assert(hasInnovateEvent || hasInnovateGene, 'LLM should produce at least one innovate-typed object. Got ' + objs.length + ' objects: ' + JSON.stringify(objs.map(function(o) { return { type: o.type, intent: o.intent, category: o.category }; }))); - process.stdout.write(' (innovate_event=' + hasInnovateEvent + ', innovate_gene=' + hasInnovateGene + ')\n'); - }); -}); - -// --------------------------------------------------------------------------- -// Summary (runs after all sync + async tests) -// --------------------------------------------------------------------------- - -function printSummary() { - var passed = 0; - var failed = 0; - var skipped = 0; - for (var i = 0; i < results.length; i++) { - if (results[i].skipped) skipped++; - else if (results[i].ok) passed++; - else failed++; - } - var total = results.length; - - process.stdout.write('\n'); - var summary = '[VIBE] ' + passed + '/' + total + ' passed'; - if (skipped > 0) summary += ', ' + skipped + ' skipped'; - if (failed > 0) { - summary += ', ' + failed + ' FAILED'; - process.stdout.write(summary + '.\n'); - process.stdout.write('[VIBE] Failures:\n'); - for (var j = 0; j < results.length; j++) { - if (!results[j].ok && !results[j].skipped) { - process.stdout.write(' - ' + results[j].id + ' ' + results[j].name + ': ' + results[j].error + '\n'); - } - } - } else { - process.stdout.write(summary + '. Ready to ship.\n'); - } - - process.exit(failed > 0 ? 1 : 0); -} - -// Execute async tests sequentially, then print summary -function runAsyncChain(idx) { - if (idx >= asyncTests.length) { printSummary(); return; } - var t = asyncTests[idx]; - var t0 = Date.now(); - var p; - try { - p = t.fn(); - } catch (e) { - var dt0 = Date.now() - t0; - var msg0 = e && e.message ? e.message : String(e); - results.push({ id: t.id, name: t.name, ok: false, dt: dt0, error: msg0 }); - process.stdout.write('[VIBE] ' + t.id + ' ' + pad(t.name + ' ', 30) + ' FAIL (' + dt0 + 'ms)\n'); - process.stdout.write(' -> ' + msg0 + '\n'); - runAsyncChain(idx + 1); - return; - } - if (!p || typeof p.then !== 'function') { - var dt1 = Date.now() - t0; - results.push({ id: t.id, name: t.name, ok: true, dt: dt1, error: null }); - process.stdout.write('[VIBE] ' + t.id + ' ' + pad(t.name + ' ', 30) + ' PASS (' + dt1 + 'ms)\n'); - runAsyncChain(idx + 1); - return; - } - p.then(function () { - var dt = Date.now() - t0; - results.push({ id: t.id, name: t.name, ok: true, dt: dt, error: null }); - process.stdout.write('[VIBE] ' + t.id + ' ' + pad(t.name + ' ', 30) + ' PASS (' + dt + 'ms)\n'); - runAsyncChain(idx + 1); - }).catch(function (e) { - var dt = Date.now() - t0; - var msg = e && e.message ? e.message : String(e); - results.push({ id: t.id, name: t.name, ok: false, dt: dt, error: msg }); - process.stdout.write('[VIBE] ' + t.id + ' ' + pad(t.name + ' ', 30) + ' FAIL (' + dt + 'ms)\n'); - process.stdout.write(' -> ' + msg + '\n'); - runAsyncChain(idx + 1); - }); -} - -runAsyncChain(0);