# Utility digest - part 8/8 (scope: workspace/agents/opencode) ================ File: workspace/agents/opencode/engine/agent.md ================ # Workflow State Engine — Orchestration Toolkit **Contract-driven state machine orchestration engine for AI agent workflows.** State machine: `INIT → PLAN → PLAN_SCORED → EXECUTE → EXECUTE_SCORED → REVIEW → REVIEW_SCORED → COMPLETE` Single source of truth for all AI agents. Reference skills and usage guides for depth. This file is `instructions[0]` — loaded by every agent at session start. [![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![License][license-shield]][license-url] ## Table of Contents 1. [1. Project Overview](#1-project-overview) 2. [2. Framework Architecture](#2-framework-architecture) 3. [3. Agent Reference — 16 Agents (Scrum Team)](#3-agent-reference--16-agents-scrum-team) 4. [4. Tool Usage Reference](#4-tool-usage-reference) 5. [5. Development Workflow](#5-development-workflow) 6. [6. Non-Negotiable Rules](#6-non-negotiable-rules) 7. [7. Skills Reference](#7-skills-reference)
## 1. Project Overview ### Core Concepts | Concept | Description | |---------|-------------| | **Shared JSON Envelope** | `session/contract.json` — single source of truth for state, decisions, outputs, scoring | | **State Machine** | 8 forward-flow states + BLOCKED escalation; agents transition through the workflow via the envelope | | **Scoring Pipeline** | Three-tier scoring after every delegation (rule checks → LLM-as-judge → combined verdict) | | **Agent Delegation** | Orchestrator delegates to specialized scrum-team agents (product-manager, system-analyst, developer, devops-engineer, quality-analyst, security-engineer, scrum-master, …) | | **Cross-Model Review** | Reviewers run on a different model than executors; conflicting verdicts → BLOCKED, arbitrated by `council` | | **Cross-Session Learning** | Lessons, patterns, gotchas persisted via `ctx_knowledge` | ### State Machine Transitions ``` INIT → PLAN → PLAN_SCORED → EXECUTE → EXECUTE_SCORED → REVIEW → REVIEW_SCORED → COMPLETE ↘ ↘ ↘ BLOCKED (score < 50 or retry ≥ 3) ↘ User intervention → retry with guidance ``` | Transition | Gate | Condition | |-----------|------|-----------| | INIT → PLAN | Session created | Always | | PLAN → PLAN_SCORED | Plan produced | Always | | PLAN_SCORED → EXECUTE | Plan accepted | Score ≥ 70 | | EXECUTE → EXECUTE_SCORED | Implementation done | Always | | EXECUTE_SCORED → REVIEW | Code review | Score ≥ 70 | | REVIEW → REVIEW_SCORED | Review done | Always | | REVIEW_SCORED → COMPLETE | All gates pass | Score ≥ 70 | | Any → BLOCKED | Escalation | Score < 50 or retry ≥ 3 | ### Scoring Pipeline (Three-Tier) 1. **Tier 1 — Rule-Based Checks**: Schema valid (-15), permissions violated (-40), blast radius HIGH (-40), writing order wrong (-15), required fields missing (-15). Subtotal ≥ 70 → Tier 2. 2. **Tier 2 — LLM-as-Judge**: Scores 0-100 on requirements fulfillment (0-40), governance compliance (0-30), completeness (0-20), edge cases (0-10). 3. **Tier 3 — Combined Verdict**: PASS (≥70), RETRY (50-69, max 3 attempts), BLOCKED (<50). ### Architecture ``` agents/ → 16 agent instruction files (orchestrator, product-manager, scrum-master, developer, devops-engineer, quality-analyst, security-engineer, …) skills/ → 66 skill directories (system-analyst, writing-plans, spec-driven-development, …) commands/ → slash commands (impeccable) contract/ → contract.template.json, contract.schema.json, state.template.md rules/ → rules.json (state machine transitions, scoring thresholds, cross-model review) doc/ → workflow guides, project.md, gap analysis, reports usage/ → 4 tool usage guides (lean-ctx, graphify, context7, opencode) ``` --- ## 2. Framework Architecture ### Deployment Layout The engine ships inside the container image (`COPY engine/ /opt/engine/`) and is exposed to OpenCode through symlinks in the config directory. Agents read it at `/opt/engine/`: ``` ~/.config/opencode/ ├── opencode.json → (bind-mounted) config/opencode.json ├── agent.md → /opt/engine/agent.md (instructions[0]) ├── /opt/engine/agents/ → /opt/engine/agents/ 16 agent instruction files ├── /opt/engine/skills/ → /opt/engine/skills/ 66 skill directories ├── /opt/engine/commands/ → /opt/engine/commands/ slash commands ├── /opt/engine/rules/ → /opt/engine/rules/ rules.json (state machine) ├── /opt/engine/contract/ → /opt/engine/contract/ contract.template.json, contract.schema.json, state.template.md ├── /opt/engine/usage/ → /opt/engine/usage/ 4 tool usage guides └── /opt/engine/doc/ → /opt/engine/doc/ workflow guides, reports ``` Engine changes take effect on container rebuild; the bind-mounted config on restart. ### Directory Tree The `engine/` source mirrors the runtime layout — edit here, rebuild to deploy: ``` ├── agent.md ← THIS FILE (instructions[0]; includes the §lean-ctx code-intelligence block) ├── README.md ← Project overview (diagram, badges) ├── /opt/engine/agents/ ← 16 agent instruction files ├── /opt/engine/doc/ ← Workflow guides, project.md, reports ├── /opt/engine/rules/ ← rules.json (state machine, scoring, cross-model review) ├── /opt/engine/skills/ ← 66 skill directories ├── /opt/engine/commands/ ← slash commands ├── /opt/engine/contract/ ← contract.template.json, contract.schema.json, state.template.md ├── /opt/engine/usage/ ← 4 tool usage guides └── /opt/engine/scripts/ ← Automation & validation scripts ``` --- ## 3. Agent Reference — 16 Agents (Scrum Team) > Full agent instruction files at `/opt/engine/agents/` (symlinked into the OpenCode config dir). Each file defines role, MCPs, skills, and post-flight protocol. > > **Model tiers**: `executor` = `opencode/big-pickle` (build/plan tier — developer, fixer, system-analyst); `default` = `sumopod/glm-5.3-flash`, fallback `sumopod/deepseek-flash` (all other agents). Reviewers never share the executor's model — this is the basis of **cross-model review**. ### Orchestrator | Agent | Role | Key MCPs | Access | Contract States | Model | Skills | File | |-------|------|----------|--------|----------------|-------|--------|------| | **orchestrator** | Scrum lead / primary orchestrator — delegates, scores, drives the state machine | lean-ctx, context7 | Read/write | `*` (all) | default | (orchestrator — agent.md) | `/opt/engine/agents/orchestrator.md` | | **design-orchestrator** | UI/UX specialist orchestrator — enforces 4-layer design pipeline (Exemplar > Generator > Taste > Gate) | lean-ctx, context7 | Read/write | `*` (all) | default | design-exemplar, ui-ux-pro-max, design-taste-frontend, impeccable | `/opt/engine/agents/design-orchestrator.md` | ### Product & Planning Agents (read-only) | Agent | Role | Key MCPs | Access | Contract States | Model | Skills | File | |-------|------|----------|--------|----------------|-------|--------|------| | **product-manager** | Requirements + acceptance-criteria backlog (GWT) | lean-ctx, context7 | Read-only | `INIT`, `PLAN` | default | business-analyst, brainstorming, writing-plans, diagram-design | `/opt/engine/agents/product-manager.md` | | **system-analyst** | Impact analysis, specs, detailed plans | lean-ctx | Read-only | `INIT`, `PLAN`, `PLAN_SCORED` | executor | system-analyst, business-analyst, writing-plans, diagram-design | `/opt/engine/agents/system-analyst.md` | | **software-architect** | Architecture trade-offs, tech-debt assessment | lean-ctx | Read-only | `INIT`, `PLAN`, `PLAN_SCORED`, `REVIEW` | default | system-analyst, java-developer, diagram-design | `/opt/engine/agents/software-architect.md` | ### Implementation Agents (read/write) | Agent | Role | Key MCPs | Access | Contract States | Model | Skills | File | |-------|------|----------|--------|----------------|-------|--------|------| | **developer** | Implements the plan, writes tests alongside code | lean-ctx | Read/write | `EXECUTE`, `EXECUTE_SCORED` | executor | test-driven-development, software-developer, design-taste-frontend, impeccable | `/opt/engine/agents/developer.md` | | **devops-engineer** | CI/CD, containerization, deploy, migrations, observability, rollback | lean-ctx | Read/write | `EXECUTE`, `EXECUTE_SCORED`, `REVIEW` | default | devops-expert, observability-and-instrumentation, diagram-design | `/opt/engine/agents/devops-engineer.md` | | **fixer** | Bounded, scoped fixes for well-defined tasks | lean-ctx | Read/write | `EXECUTE` | executor | systematic-debugging | `/opt/engine/agents/fixer.md` | ### Quality & Process Agents (read-only) | Agent | Role | Key MCPs | Access | Contract States | Model | Skills | File | |-------|------|----------|--------|----------------|-------|--------|------| | **quality-analyst** | Independent quality review (5-axis: correctness, readability, architecture, security, performance) | lean-ctx | Read-only | `REVIEW`, `REVIEW_SCORED` | default | qa-expert, security-expert, code-review-and-quality, impeccable | `/opt/engine/agents/quality-analyst.md` | | **security-engineer** | Independent security review — threat model, vulns, secrets, trust boundaries | lean-ctx | Read-only | `REVIEW`, `REVIEW_SCORED` | default | security-expert, code-review-and-quality | `/opt/engine/agents/security-engineer.md` | | **scrum-master** | Process owner — guards the contract, state machine, and workflow; unblocks BLOCKED, DoD gate | lean-ctx | Read-only | `*` (all), `BLOCKED` | default | orchestration-template, audit-observability, adaptive-solver | `/opt/engine/agents/scrum-master.md` | | **learner** | Post-execution retro — extract lessons, gotchas, patterns | lean-ctx | Read-only | `REVIEW_SCORED`, `COMPLETE` | default | verification-before-completion | `/opt/engine/agents/learner.md` | ### Support Agents (read-only) | Agent | Role | Key MCPs | Access | Contract States | Model | Skills | File | |-------|------|----------|--------|----------------|-------|--------|------| | **explorer** | Fast codebase search / unknowns discovery | lean-ctx | Read-only | `*` (all) | default | (exploration) | `/opt/engine/agents/explorer.md` | | **librarian** | External docs / library research | context7 | Read-only | `*` (all) | default | (research) | `/opt/engine/agents/librarian.md` | | **council** | Multi-model consensus + cross-model arbitration | lean-ctx | Read-only | `*` (all) | default | (multi-LLM consensus) | `/opt/engine/agents/council.md` | | **observer** | Visual analysis — images, PDFs, diagrams | lean-ctx | Read-only | `*` (all) | default | (visual analysis), impeccable, diagram-design | `/opt/engine/agents/observer.md` | ### Lane Name Mapping | Lane | Agent | subagent_type | |------|-------|---------------| | `@product-manager` | product-manager | `product-manager` | | `@scrum-master` | scrum-master | `scrum-master` | | `@devops-engineer` | devops-engineer | `devops-engineer` | | `@security-engineer` | security-engineer | `security-engineer` | | `@explorer` | explorer | `explorer` | | `@librarian` | librarian | `librarian` | | `@fixer` | fixer | `fixer` | | `@council` | council | `council` | | `@observer` | observer | `observer` | ### Agent State Machine See [`/opt/engine/doc/workflow.md`](/opt/engine/doc/workflow.md) for the canonical state machine, scoring pipeline, gates, and validation rules. --- ## 4. Tool Usage Reference ### 4.1 Quick Reference | Tool | What It Does | Guide | |------|-------------|-------| | **lean-ctx** | File reads, shell, search, edit, impact analysis, knowledge persistence, sessions | `/opt/engine/usage/lean-ctx.md` | | **Graphify** | Knowledge graph, community discovery, PR impact | `/opt/engine/usage/graphify.md` | | **Context7** | Library/framework docs lookup | `/opt/engine/usage/context7.md` | | **rag MCP** (`rag_search` / `rag_index` / `rag_status` / `rag_get`) | Semantic search over workspace code, docs, and engine skills before unfamiliar work | `/home/node/workspace/opencode/rag/README.md` | | **Firecrawl plugin** | First-party V2 plugin: keyless developer search, keyed alexandria gated to ask, CLI skills registered at runtime | `/opt/engine/plugins/opencode-firecrawl/VENDORED.md` | ### 4.3 Tool Comparison Matrix | Task | Best Tool | Why | |------|-----------|-----| | **Check blast radius before editing** | `ctx_impact` | Knowledge graph tracks callers/importers at d=1/d=2/d=3 depths | | **Explore unfamiliar code flows** | `ctx_compose` | Returns process-grouped execution flows ranked by relevance | | **Read/edit files** | `lean-ctx ctx_read`/`ctx_edit` | Cached reads (~13 tok), safe search-and-replace edits | | **Shell commands** | `lean-ctx ctx_shell` | Compressed output, 95+ patterns, replaces bash | | **Persist decisions/knowledge** | `lean-ctx ctx_knowledge remember` | Cross-session persistence with embeddings | | **Save/resume sessions** | `lean-ctx ctx_session` | Survives OpenCode restarts | | **External library research** | `context7_query_docs` | Official docs lookups with code examples | | **Primary-source passages (issues, PRs, READMEs)** | `firecrawl_developer_search` | Keyless plugin search over raw sources; best for error hunting and API contract questions | | **Single arbitrary page** | fetch MCP (`fetch`) | Keyless one-off HTTP GET; zero-dependency fallback | | **Multi-model consensus** | `council_session` | Fresh-context adversarial review for high-stakes decisions | ### 4.4 Retrieval Balance (rag MCP) `rag_search` over the workspace index is advisory, not a gate. Use it when entering an unfamiliar area, discovering project conventions, surveying existing implementations before writing code, or answering questions that span workspace code and docs. Skip it when the file is already in context, when `ctx_search` answers an exact-symbol lookup, or when the edit is trivial. Guardrails: 1. Snippets are discovery anchors: open the cited file region with `ctx_read` before editing or quoting it as fact. 2. Stale index: if files changed since the last index, run `rag_index` first (incremental, hash-based, cheap). 3. Empty or weak hits: say so and fall back to `ctx_search` and direct reads. Never present memory-filled gaps as project facts. 4. Cite hits as `path:startline` so humans can verify. 5. Skip the search when the answer is already in context or you hold the exact file; `rag_search` is for discovery across the workspace, not for reading a known file. Index scope: `/home/node/workspace`, built automatically at session boot when the server starts; run `rag_index` after bulk changes. ### 4.5 Retrieval Routing — ask the right layer once Every retrieval question has exactly one right tool. Query the wrong layer and you pay an inference round-trip for nothing. | Question type | Tool (1 call) | Never do | |------|------|------| | Exact symbol, callers, blast radius | `ctx_search` symbol / `ctx_impact` | grep sweeps across files | | "How does our code do X" | `rag_search` | opening many files to re-derive | | Library / framework API | `context7` | guessing from memory, random web search | | Errors, issues, PR discussions | `firecrawl_developer_search` | broad web search | | Single known page | `fetch` | scrape-first patterns | | Past decisions, conventions, lessons | `ctx_knowledge recall` | re-deriving from scratch | Scope `rag_search` with `glob` (e.g. `agents/opencode/engine/**`) before widening the query. Run `rag_index` after bulk edits — it is incremental and hash-based. --- ## 5. Development Workflow ### Session Model Work is tracked in a single `session/` directory — no git branches required. - All contract state lives in `session/contract.json`; snapshots are archived in `session/`. - The engine is repository-agnostic — never block on branch or VCS checks. - Every session is self-contained and resumable from `session/`. ### Before Any Edit ``` 1. ctx_read / ctx_compose — understand the code first 2. ctx_impact({target: "symbol", direction: "upstream"}) # blast radius 3. If HIGH/CRITICAL → warn user before proceeding ``` Run this ladder in order before writing ANY code: ``` 1. Does this need to exist? → skip it (YAGNI) 2. Standard library does it? → use it 3. Native platform feature? → use it 4. Already-installed dependency? → use it 5. Can this be one line? → one line 6. Only then: minimum code that works ``` **Not sacrificed:** Input validation at trust boundaries, data-loss error handling, security, accessibility, anything explicitly requested. Non-trivial logic leaves ONE runnable check (assert-based, no framework). ### Orchestration Flow (Scrum Team — Spec-Driven Development) 1. **Refine phase** — Delegate to @product-manager: capture intent, write Given-When-Then acceptance criteria, declare scope + non-goals. Attach user-journey or story-map diagrams via diagram-design when intent spans multiple actors. 2. **Discuss phase** — 5-lens check: Business, System, Dev, QA, DevOps. Clarify requirements. - **Domain context (advisory)**: `rag_search` supplies domain context when the request touches unfamiliar code or docs. 3. **Plan phase** — Delegate to @system-analyst: impact analysis, edge cases, implementation plan. @software-architect reviews the plan (independent model). Plans spanning >3 files or crossing services include an architecture/impact diagram (diagram-design). 4. **Spec gate** (if >3 files, cross-service, or >30 min) — Write GWT-format specs. Cross-examine via DDD. 5. **Execute phase** — Delegate to @developer: implement plan step by step, write tests alongside code. @devops-engineer owns pipeline/deploy/migration. @fixer for scoped fixes. UI tasks load design-taste-frontend; developer passes the impeccable detector before marking UI work done (EXECUTE exit gate). Advisory retrieval: the developer consults `rag_search` for project conventions before writing non-trivial code, then verifies hits against the file before editing. - **Context gate**: @developer loads project standards via `/skill context-standards` before edits when the target project defines `context/` files; @quality-analyst verifies compliance during review. 6. **Review phase** — Delegate to @quality-analyst (5-axis) AND @security-engineer (security) on the **reviewer model**, independent from the executor. Conflicting verdicts → BLOCKED, arbitrated by @council. UI diffs get the impeccable design-lens (slop detector) inside the readability axis. 7. **Process gate** — @scrum-master audits transitions, enforces gates, and owns the Definition of Done before COMPLETE. Unblocks BLOCKED sessions. 8. **Verify loop** — Run build commands, check conventions. Executors and fixers run `scripts/loop-validate.sh` as the inline done-gate before REVIEW. 9. **Ship** — Resolve issues, confirm deploy safety, run post-flight protocol; @learner captures lessons. 10. **Humanizer gate** — run `/skill humanizer` over all user-facing outputs shipped this session (READMEs, docs, PR descriptions, changelogs, release notes) before declaring COMPLETE. Docs that still carry AI patterns (em dashes, rule-of-three, promotional tone) are not done. Mandatory for user-facing text outputs; internal state files (session/state.md, contract JSON, code comments) are exempt. ### UI/UX Pipeline (Mandatory) All UI work runs the 4-layer pipeline in this order — enforced by @design-orchestrator: | # | Layer | Skill | Role | |---|-------|-------|------| | 1 | Exemplar | `design-exemplar` | Copy a proven look from the 84-brand DESIGN.md library (Linear, Stripe, Vercel, …) when a reference brand is named | | 2 | Generator | `ui-ux-pro-max` | BM25-search a fitting design system (styles, palettes, fonts, patterns) for the user's own product | | 3 | Taste | `design-taste-frontend` | Art direction pass — kill generic "AI-slop" patterns, enforce gallery-grade composition | | 4 | Gate | `impeccable` | No UI task is done without an impeccable design-lens review | Layer selection: user names a reference brand → Exemplar; user describes their own product → Generator; neither → Generator; trivial tweak (<10 lines, no new components) → pipeline may be skipped, impeccable gate still applies. Rules: order is mandatory; never mix Exemplar + Generator for the same page; DESIGN.md (page) / MASTER.md (global) persistence per ui-ux-pro-max `--persist`. Use **Doubt-Driven Development (DDD)** when uncertain: spawn a fresh-context adversarial reviewer to cross-examine non-trivial decisions before they stand. ### Before Commit (Post-Flight Protocol) The canonical post-flight protocol is the **E1 table in [`doc/workflow/05-operations.md`](/opt/engine/doc/workflow/05-operations.md)** (8 steps: validate contract → impact verify → change detect → knowledge persist → session archive → save conversation → re-index graphify → metrics aggregation). Run it before every commit — do not maintain a second step list here. **Pre-flight**: `bash /opt/engine/scripts/validate-contract.sh --file session/contract.json --score` (score < 70 = BLOCKED) **Parallel conflict check**: `bash /opt/engine/scripts/detect-parallel-conflicts.sh --file1 /tmp/a.txt --file2 /tmp/b.txt` **Atomic persist**: `bash /opt/engine/scripts/persist-contract.sh --file session/contract.json --inject-score 85` | Exceptions: docs-only changes skip impact, change-detect, and re-index steps. Config-only changes skip impact and change-detect. ### Save Session Protocol When the user says "save session" or a phase completes, save to **ALL** systems: > **LOCAL-ONLY:** `session/`, `.session/` and everything inside them are runtime state — NEVER `git add`, commit, or push them (gitignored; history grows unbounded). Archive historical state logs to `.session/` instead. ```bash # 1. Persist orchestration envelope to lean-ctx knowledge lean-ctx ctx_knowledge remember key orchestration-contract value "[--k N] [--glob P] | status | get` * MCP — no args: JSON-RPC 2.0 over stdio (newline-delimited). Only JSON-RPC * messages go to stdout; all diagnostics go to stderr. * * Storage: SQLite (node:sqlite, WAL) with FTS5 for keyword ranking plus * Float32 vectors for cosine similarity. Embeddings default to deterministic * local hashed n-gram vectors (fully offline); set RAG_EMBEDDINGS_URL and * RAG_EMBEDDINGS_MODEL to upgrade to an OpenAI-compatible /v1/embeddings * endpoint. */ // Attach a warning listener BEFORE loading node:sqlite so the // ExperimentalWarning is captured instead of printed to stderr. process.on('warning', (w) => { if (w && w.name === 'ExperimentalWarning') return; console.error(w && w.stack ? w.stack : String(w)); }); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const { DatabaseSync } = require('node:sqlite'); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const SERVER_NAME = 'rag'; const SERVER_VERSION = '1.0.0'; const DEFAULT_ROOT = '/home/node/workspace'; const DEFAULT_DB = path.join(os.homedir(), '.local/share/opencode/rag/rag.db'); const MAX_FILE_SIZE = 512 * 1024; const CHUNK_TARGET = 1200; // chars per chunk (paragraph accumulation) const CHUNK_OVERLAP = 150; // chars carried into the next chunk const CHUNK_HARD_SPLIT = 4000; // single paragraph above this gets windowed const JAVA_CHUNK_MIN = 400; // closed member below this merges into the next chunk const JAVA_CHUNK_HARD = 1400; // java chunk above this is split at the next line const LOCAL_DIMS = 256; // hashed n-gram vector width const EMBED_BATCH = 32; // max texts per remote embeddings request const FTS_TOP = 50; // fts rows joined into the candidate set const FTS_TOP_LARGE = 200; // fts rows when the corpus is too big to scan const FULL_SCAN_LIMIT = 2000; // full vector scan below this chunk count const FUSE_KEYWORD = 0.6; // bm25 rank weight const FUSE_VECTOR = 0.4; // cosine weight const SNIPPET_CHARS = 200; const SKIP_DIRS = new Set([ '.git', 'node_modules', '__pycache__', '.next', 'dist', 'pre-install', 'graphify-out', 'session', '.session', '.venv', // Java / JVM build outputs and IDE metadata 'target', 'build', '.gradle', '.mvn', 'out', '.settings', '.idea', 'bin', '.classpath', ]); // Exact filename matches: dependency lockfiles and OS metadata never index. const NAME_SKIPS = new Set([ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'gradle.lockfile', '.DS_Store', 'Thumbs.db', ]); // Extensions that are never worth indexing even when they decode as text. // Checked after the null-byte binary sniff, so genuinely binary formats // still count as skipped_binary. svg stays indexed (it is text). const EXT_SKIPS = new Set([ 'jar', 'war', 'zip', 'tar', 'gz', 'tgz', '7z', 'rar', 'class', 'exe', 'dll', 'so', 'dylib', 'a', 'o', 'obj', 'png', 'jpg', 'jpeg', 'gif', 'ico', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'mp3', 'mp4', 'mov', 'avi', 'sqlite', 'db', 'wasm', 'node', 'pyc', 'pyo', 'bin', 'iso', 'img', ]); // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- function logErr(msg) { console.error(`[rag] ${msg}`); } function tokenize(text) { return String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); } function fnv1a(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); } function expandTilde(p) { if (p === '~') return os.homedir(); if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2)); return p; } function nowIso(epoch) { const ms = epoch === undefined ? Date.now() : epoch; return new Date(ms).toISOString(); } function snippetOf(text) { return String(text || '').replace(/\s+/g, ' ').trim().slice(0, SNIPPET_CHARS); } /** * Simple glob matcher supporting * (within a path segment), ** (across * segments) and ? (single character). Anchored on the whole relative path. */ function globToRegExp(pattern) { let re = '^'; for (let i = 0; i < pattern.length; i++) { const c = pattern[i]; if (c === '*') { if (pattern[i + 1] === '*') { re += '.*'; i++; } else { re += '[^/]*'; } } else if (c === '?') { re += '[^/]'; } else { re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); } } return new RegExp(re + '$'); } // --------------------------------------------------------------------------- // Embeddings // --------------------------------------------------------------------------- /** * Local deterministic hashed n-gram embedding: word unigrams + bigrams plus * char 4-grams of each boundary-padded word, hashed into D buckets with * fnv1a, L2 normalized. No I/O, stable across runs and machines. */ function localEmbed(text, dims) { const vec = new Float32Array(dims); const tokens = tokenize(text); const feats = []; for (let i = 0; i < tokens.length; i++) { const w = tokens[i]; feats.push(w); if (i + 1 < tokens.length) feats.push(`${w}_${tokens[i + 1]}`); const padded = `#${w}#`; for (let j = 0; j + 4 <= padded.length; j++) feats.push(padded.slice(j, j + 4)); } for (const f of feats) vec[fnv1a(f) % dims] += 1; let norm = 0; for (let i = 0; i < dims; i++) norm += vec[i] * vec[i]; norm = Math.sqrt(norm); if (norm > 0) for (let i = 0; i < dims; i++) vec[i] /= norm; return vec; } function embeddingsConfig() { const url = process.env.RAG_EMBEDDINGS_URL; const model = process.env.RAG_EMBEDDINGS_MODEL; if (!url || !model) return null; return { url, model, key: process.env.RAG_EMBEDDINGS_KEY || '' }; } async function embedBatchRemote(texts, cfg) { const res = await fetch(cfg.url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}`, }, body: JSON.stringify({ model: cfg.model, input: texts }), signal: AbortSignal.timeout(30000), }); if (!res.ok) throw new Error(`embeddings endpoint returned HTTP ${res.status}`); const body = await res.json(); const data = body && body.data; if (!Array.isArray(data) || data.length !== texts.length) { throw new Error('embeddings endpoint returned an unexpected shape'); } return data.map((d) => { if (!Array.isArray(d.embedding) || d.embedding.length === 0) { throw new Error('embeddings endpoint returned an empty embedding'); } return Float32Array.from(d.embedding); }); } /** Embed all texts remotely, sequentially in batches of EMBED_BATCH. */ async function embedAllRemote(texts, cfg) { const out = []; for (let i = 0; i < texts.length; i += EMBED_BATCH) { const batch = texts.slice(i, i + EMBED_BATCH); out.push(...await embedBatchRemote(batch, cfg)); } return out; } async function embedQueryRemote(text, cfg) { const [vec] = await embedBatchRemote([text], cfg); return vec; } function vecToBlob(vec) { return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength); } function blobToVec(buf) { const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); return new Float32Array(ab); } function cosine(a, b) { let dot = 0; let na = 0; let nb = 0; const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } if (na === 0 || nb === 0) return 0; return dot / Math.sqrt(na * nb); } // --------------------------------------------------------------------------- // Chunking // --------------------------------------------------------------------------- /** * Windowed hard split for oversized single paragraphs: ~CHUNK_TARGET char * windows with CHUNK_OVERLAP char overlap, cutting at whitespace when close * to the window edge. */ function splitLongParagraph(text, startLine) { const out = []; let pos = 0; let line = startLine; while (pos < text.length) { let end = Math.min(pos + CHUNK_TARGET, text.length); if (end < text.length) { const cut = text.lastIndexOf(' ', end); if (cut > pos + CHUNK_TARGET - 200) end = cut; } const slice = text.slice(pos, end); const nl = (slice.match(/\n/g) || []).length; out.push({ text: slice, startLine: line, endLine: line + nl }); line += nl; if (end >= text.length) break; const next = end - CHUNK_OVERLAP; pos = next > pos ? next : end; } return out; } /** Split plain text / code on blank-line boundaries, ~1200 chars, ~150 overlap. */ function chunkPlainText(text) { const paras = []; let cur = null; const lines = text.split('\n'); lines.forEach((ln, i) => { const n = i + 1; if (ln.trim() === '') { if (cur) { paras.push(cur); cur = null; } } else { if (!cur) cur = { startLine: n, lines: [] }; cur.lines.push(ln); cur.endLine = n; } }); if (cur) paras.push(cur); for (const p of paras) p.text = p.lines.join('\n'); const chunks = []; let acc = []; let accLen = 0; const flush = () => { if (!acc.length) return; chunks.push({ text: acc.map((a) => a.text).join('\n'), startLine: acc[0].startLine, endLine: acc[acc.length - 1].endLine, }); }; for (const p of paras) { if (p.text.length > CHUNK_HARD_SPLIT) { if (acc.length) { flush(); acc = []; accLen = 0; } chunks.push(...splitLongParagraph(p.text, p.startLine)); continue; } if (accLen + p.text.length > CHUNK_TARGET && accLen > 0) { flush(); const tail = []; let tlen = 0; for (let j = acc.length - 1; j >= 0; j--) { if (tlen + acc[j].text.length > CHUNK_OVERLAP && tail.length) break; tail.unshift(acc[j]); tlen += acc[j].text.length + 1; } acc = tail; accLen = tlen; } acc.push(p); accLen += p.text.length + 1; } flush(); return chunks; } /** Split markdown on headings, keeping the heading path per chunk. */ function chunkMarkdown(text) { const lines = text.split('\n'); const stack = []; // { level, title } let cur = { startLine: 1, lines: [] }; const sections = []; lines.forEach((ln, i) => { const n = i + 1; const m = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(ln); if (m) { sections.push({ heading: stack.map((s) => s.title).join(' > '), startLine: cur.startLine, lines: cur.lines, }); const level = m[1].length; const title = m[2].trim(); while (stack.length && stack[stack.length - 1].level >= level) stack.pop(); stack.push({ level, title }); cur = { startLine: n, lines: [ln] }; } else { cur.lines.push(ln); } }); sections.push({ heading: stack.map((s) => s.title).join(' > '), startLine: cur.startLine, lines: cur.lines, }); const out = []; for (const s of sections) { const body = s.lines.join('\n'); if (!body.trim()) continue; if (body.length > CHUNK_HARD_SPLIT) { for (const piece of splitLongParagraph(body, s.startLine)) { out.push({ heading: s.heading, ...piece }); } } else { out.push({ heading: s.heading, text: body, startLine: s.startLine, endLine: s.startLine + s.lines.length - 1, }); } } return out; } function isMarkdown(relPath) { return /\.(md|markdown)$/i.test(relPath); } function isJava(relPath) { return /\.java$/i.test(relPath); } /** * Split Java source on member boundaries using brace depth. Naive scan: * braces are counted outside line comments, string literals, and char * literals; block comments are not tracked. A chunk closes when depth * returns to the class-body level (1) and JAVA_CHUNK_MIN chars have * accumulated; any chunk above JAVA_CHUNK_HARD chars is hard split at the * next line boundary. Headings carry package > class > method context * (best effort: constructors use the class name, unparseable files fall * back to chunkPlainText). Chunk text is always a raw slice of the file; * the heading is metadata only, same as markdown. */ function chunkJava(text, relPath) { const lines = text.split('\n'); const base = path.basename(relPath || '').replace(/\.java$/i, '') || 'file'; const memberRe = /([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g; let pkg = ''; let cls = ''; let method = ''; let depth = 0; let sawDepth = false; const chunks = []; let cur = null; // { startLine, lines, heading, members, len } const headingNow = () => { const parts = []; if (pkg) parts.push(`package ${pkg};`); parts.push(cls || base); if (method) parts.push(method); return parts.join(' > '); }; const flush = () => { if (!cur) return; const body = cur.lines.join('\n'); if (body.trim()) { chunks.push({ heading: cur.heading, text: body, startLine: cur.startLine, endLine: cur.startLine + cur.lines.length - 1, }); } cur = null; }; for (let i = 0; i < lines.length; i++) { const ln = lines[i]; const n = i + 1; // Scan the line: brace depth plus package/class/method context. let inStr = false; let inChr = false; let inComment = false; let enteredMember = false; // depth crossed 1 -> 2 (a member body opened) let leftMember = false; // depth returned to 1 (a member body closed) for (let j = 0; j < ln.length; j++) { const c = ln[j]; if (inComment) break; if (inStr) { if (c === '\\') j++; else if (c === '"') inStr = false; continue; } if (inChr) { if (c === '\\') j++; else if (c === "'") inChr = false; continue; } if (c === '/' && ln[j + 1] === '/') { inComment = true; continue; } if (c === '"') { inStr = true; continue; } if (c === "'") { inChr = true; continue; } if (c === '{') { depth++; sawDepth = true; if (depth === 2) enteredMember = true; } else if (c === '}') { if (depth > 0) depth--; if (depth === 1) leftMember = true; } } if (!pkg) { const pm = /^\s*package\s+([\w.]+)\s*;/.exec(ln); if (pm) pkg = pm[1]; } if (!cls) { const cm = /^\s*(?:@\w+\s+)*(?:public\s+|protected\s+|private\s+|abstract\s+|final\s+|sealed\s+|static\s+)*(?:class|interface|enum|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)/.exec(ln); if (cm) cls = cm[1]; } if (enteredMember) { // The method name is the last identifier followed by "(" in the // lines accumulated since the previous member closed. const buf = cur ? cur.lines.concat(ln).join('\n') : ln; let m; let last = null; memberRe.lastIndex = 0; while ((m = memberRe.exec(buf))) last = m[1]; if (last) method = last; } if (!cur) cur = { startLine: n, lines: [], heading: headingNow(), members: 0, len: 0 }; if (enteredMember) { cur.members++; if (cur.members === 1) cur.heading = headingNow(); } cur.lines.push(ln); cur.len += ln.length + 1; if ((leftMember && cur.len >= JAVA_CHUNK_MIN) || cur.len > JAVA_CHUNK_HARD) flush(); } flush(); if (!sawDepth) return chunkPlainText(text); return chunks; } // --------------------------------------------------------------------------- // Database // --------------------------------------------------------------------------- function openDb(dbPathRaw) { const dbPath = expandTilde(dbPathRaw); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); db.exec('PRAGMA journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, path TEXT UNIQUE, hash TEXT, size INTEGER, mtime INTEGER, indexed_at INTEGER ); CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, file_id INTEGER, ordinal INTEGER, heading TEXT, start_line INTEGER, end_line INTEGER, text TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5( text, chunk_id UNINDEXED, tokenize='porter unicode61' ); CREATE TABLE IF NOT EXISTS vectors ( chunk_id INTEGER PRIMARY KEY, blob BLOB ); CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT); `); return db; } function metaGet(db, key) { const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key); return row ? row.value : null; } function metaSet(db, key, value) { db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(key, String(value)); } // --------------------------------------------------------------------------- // Filesystem walk // --------------------------------------------------------------------------- function walk(root, skipStats) { const out = []; const visit = (dir) => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { logErr(`cannot read ${dir}: ${e.message}`); return; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const ent of entries) { const abs = path.join(dir, ent.name); if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name)) visit(abs); continue; } if (!ent.isFile()) continue; if (NAME_SKIPS.has(ent.name)) { if (skipStats) skipStats.skipped_name++; continue; } out.push({ abs, rel: path.relative(root, abs).split(path.sep).join('/') }); } }; visit(root); return out; } function loadFile(abs, relPath) { const st = fs.statSync(abs); if (st.size > MAX_FILE_SIZE) return { skip: 'size', size: st.size, mtime: st.mtimeMs }; const buf = fs.readFileSync(abs); if (buf.includes(0)) return { skip: 'binary', size: st.size, mtime: st.mtimeMs }; const ext = path.extname(relPath || abs).slice(1).toLowerCase(); if (EXT_SKIPS.has(ext)) return { skip: 'extension', size: st.size, mtime: st.mtimeMs }; return { skip: null, buf, size: st.size, mtime: st.mtimeMs }; } // --------------------------------------------------------------------------- // Indexing // --------------------------------------------------------------------------- function deleteChunksOfFiles(db, fileIds) { if (!fileIds.length) return; const placeholders = fileIds.map(() => '?').join(','); const ids = db.prepare( `SELECT id FROM chunks WHERE file_id IN (${placeholders})` ).all(...fileIds); if (ids.length) { const ph = ids.map(() => '?').join(','); const arg = ids.map((r) => r.id); db.prepare(`DELETE FROM fts WHERE chunk_id IN (${ph})`).run(...arg); db.prepare(`DELETE FROM vectors WHERE chunk_id IN (${ph})`).run(...arg); } db.prepare(`DELETE FROM chunks WHERE file_id IN (${placeholders})`).run(...fileIds); } /** * Incremental index of root into the database. * Returns stats: { files, rechunked, purged, skipped_binary, skipped_ext, * skipped_name, chunks, dims, mode, ms }. */ async function indexRoot(db, rootRaw) { const t0 = Date.now(); const root = path.resolve(expandTilde(rootRaw)); if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { throw new Error(`root is not a directory: ${root}`); } const cfg = embeddingsConfig(); const stats = { root, files: 0, rechunked: 0, purged: 0, skipped_binary: 0, skipped_ext: 0, skipped_name: 0, chunks: 0, dims: null, mode: null, ms: 0, }; const disk = walk(root, stats); const diskByPath = new Map(disk.map((f) => [f.abs, f])); const existing = db.prepare('SELECT id, path, hash FROM files').all(); const existingByPath = new Map(existing.map((r) => [r.path, r])); // Files deleted on disk: purge chunks + fts + vectors + file row. const purgedIds = existing.filter((r) => !diskByPath.has(r.path)).map((r) => r.id); if (purgedIds.length) { db.exec('BEGIN'); try { deleteChunksOfFiles(db, purgedIds); const del = db.prepare('DELETE FROM files WHERE id = ?'); for (const id of purgedIds) del.run(id); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } stats.purged = purgedIds.length; } // Pending vectors for newly created chunks (filled during re-chunk). let pending = []; for (const f of disk) { const loaded = loadFile(f.abs, f.rel); if (loaded.skip === 'binary') { stats.skipped_binary++; continue; } if (loaded.skip === 'extension') { stats.skipped_ext++; continue; } if (loaded.skip === 'size') continue; const hash = sha256(loaded.buf); const prev = existingByPath.get(f.abs); if (prev && prev.hash === hash) continue; // unchanged: skip stats.rechunked++; const text = loaded.buf.toString('utf8'); const pieces = isJava(f.rel) ? chunkJava(text, f.rel) : isMarkdown(f.rel) ? chunkMarkdown(text) : chunkPlainText(text); db.exec('BEGIN'); try { let fileId; if (prev) { deleteChunksOfFiles(db, [prev.id]); db.prepare('UPDATE files SET hash=?, size=?, mtime=?, indexed_at=? WHERE id=?') .run(hash, loaded.size, Math.round(loaded.mtime), Date.now(), prev.id); fileId = prev.id; } else { const ins = db.prepare( 'INSERT INTO files (path, hash, size, mtime, indexed_at) VALUES (?, ?, ?, ?, ?)' ).run(f.abs, hash, loaded.size, Math.round(loaded.mtime), Date.now()); fileId = Number(ins.lastInsertRowid); } const insChunk = db.prepare( 'INSERT INTO chunks (file_id, ordinal, heading, start_line, end_line, text) VALUES (?, ?, ?, ?, ?, ?)' ); const insFts = db.prepare('INSERT INTO fts (text, chunk_id) VALUES (?, ?)'); for (let i = 0; i < pieces.length; i++) { const p = pieces[i]; const r = insChunk.run(fileId, i, p.heading || '', p.startLine, p.endLine, p.text); const chunkId = Number(r.lastInsertRowid); insFts.run(p.text, chunkId); pending.push({ id: chunkId, text: p.text }); } db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } } // --- Embedding pass ----------------------------------------------------- let mode = cfg ? 'remote' : 'local'; const prevDims = metaGet(db, 'dims'); let dims = cfg ? null : LOCAL_DIMS; if (cfg && pending.length) { try { logErr(`remote embeddings: ${pending.length} texts via ${cfg.url}`); let vectors = await embedAllRemote(pending.map((p) => p.text), cfg); dims = vectors[0].length; if (prevDims && Number(prevDims) !== dims) { // Dimensionality changed: rebuild every vector, not just new chunks. logErr(`dims changed ${prevDims} -> ${dims}; rebuilding all vectors`); db.exec('DELETE FROM vectors'); const all = db.prepare('SELECT id, text FROM chunks ORDER BY id').all(); pending = all.map((r) => ({ id: r.id, text: r.text })); vectors = await embedAllRemote(pending.map((p) => p.text), cfg); dims = vectors[0].length; } const put = db.prepare('INSERT OR REPLACE INTO vectors (chunk_id, blob) VALUES (?, ?)'); db.exec('BEGIN'); for (let i = 0; i < pending.length; i++) put.run(pending[i].id, vecToBlob(vectors[i])); db.exec('COMMIT'); } catch (e) { // Fall back to local for this run: wipe stale vectors and rebuild all. logErr(`remote embeddings failed (${e.message}); falling back to local for this run`); mode = 'local'; dims = LOCAL_DIMS; db.exec('DELETE FROM vectors'); const all = db.prepare('SELECT id, text FROM chunks ORDER BY id').all(); pending = all.map((r) => ({ id: r.id, text: r.text })); } } else if (!cfg) { if (prevDims && Number(prevDims) !== LOCAL_DIMS) { db.exec('DELETE FROM vectors'); const all = db.prepare('SELECT id, text FROM chunks ORDER BY id').all(); pending = all.map((r) => ({ id: r.id, text: r.text })); } } if (mode === 'local' && pending.length) { const put = db.prepare('INSERT OR REPLACE INTO vectors (chunk_id, blob) VALUES (?, ?)'); db.exec('BEGIN'); for (const p of pending) put.run(p.id, vecToBlob(localEmbed(p.text, LOCAL_DIMS))); db.exec('COMMIT'); dims = LOCAL_DIMS; } stats.files = db.prepare('SELECT COUNT(*) AS n FROM files').get().n; stats.chunks = db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n; // Only overwrite dims/mode when this run actually (re)embedded something; // otherwise the meta keeps describing the vectors that are already stored. if (pending.length > 0) { metaSet(db, 'dims', dims); metaSet(db, 'embedding_mode', mode); stats.dims = dims; stats.mode = mode; } else { const d = metaGet(db, 'dims'); stats.dims = d === null ? null : Number(d); stats.mode = metaGet(db, 'embedding_mode') || mode; } metaSet(db, 'last_index_at', String(Date.now())); metaSet(db, 'root', root); stats.ms = Date.now() - t0; return stats; } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- function ftsQuery(tokens, op) { return tokens.map((t) => `"${t}"`).join(` ${op} `); } async function search(db, query, opts) { const k = Math.max(1, Number(opts && opts.k) || 8); const globRe = opts && opts.glob ? globToRegExp(opts.glob) : null; const root = metaGet(db, 'root') || DEFAULT_ROOT; const mode = metaGet(db, 'embedding_mode') || 'local'; const dims = Number(metaGet(db, 'dims') || LOCAL_DIMS); const tokens = tokenize(query); let ftsRows = []; if (tokens.length) { const orRows = db.prepare( 'SELECT chunk_id, bm25(fts) AS r FROM fts WHERE fts MATCH ? ORDER BY r LIMIT ?' ).all(ftsQuery(tokens, 'OR'), FTS_TOP_LARGE); let andRows = orRows; if (tokens.length > 1) { const cand = db.prepare( 'SELECT chunk_id, bm25(fts) AS r FROM fts WHERE fts MATCH ? ORDER BY r LIMIT ?' ).all(ftsQuery(tokens, 'AND'), FTS_TOP_LARGE); if (cand.length > 0) andRows = cand; } ftsRows = andRows.length >= orRows.length ? andRows : orRows; if (ftsRows.length > 0) ftsRows = ftsRows.slice(0, Math.max(FTS_TOP, FTS_TOP_LARGE)); } // Rank normalization over the fts rows kept for scoring. const rankById = new Map(); if (ftsRows.length) { const kept = ftsRows.slice(0, FTS_TOP_LARGE); let rmin = Infinity; let rmax = -Infinity; for (const r of kept) { if (r.r < rmin) rmin = r.r; if (r.r > rmax) rmax = r.r; } for (const r of kept) { const norm = rmax === rmin ? 1 : 1 - (r.r - rmin) / (rmax - rmin); rankById.set(r.chunk_id, norm); } } const totalChunks = db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n; // Candidate set: fts top + full scan when the corpus is small enough. let candidates; if (totalChunks < FULL_SCAN_LIMIT) { candidates = db.prepare( 'SELECT c.id, c.file_id, c.heading, c.start_line, c.end_line, c.text, f.path ' + 'FROM chunks c JOIN files f ON f.id = c.file_id ORDER BY c.id' ).all(); } else { candidates = []; const byId = new Map(); const load = (id) => { if (byId.has(id)) return byId.get(id); const row = db.prepare( 'SELECT c.id, c.file_id, c.heading, c.start_line, c.end_line, c.text, f.path ' + 'FROM chunks c JOIN files f ON f.id = c.file_id WHERE c.id = ?' ).get(id); if (row) byId.set(id, row); return row; }; for (const id of rankById.keys()) { const row = load(id); if (row) candidates.push(row); } } // Query vector: remote endpoint when the index was built in remote mode. let qvec = null; if (mode === 'remote') { const cfg = embeddingsConfig(); if (cfg) { try { qvec = await embedQueryRemote(String(query), cfg); } catch (e) { logErr(`remote query embedding failed (${e.message}); scoring by keyword rank only`); } } else { logErr('index uses remote embeddings but RAG_EMBEDDINGS_URL/MODEL are unset; keyword rank only'); } } else { qvec = localEmbed(String(query), dims); } // Vectors for scoring. let vecById = null; if (qvec && totalChunks < FULL_SCAN_LIMIT) { vecById = new Map(); for (const row of db.prepare('SELECT chunk_id, blob FROM vectors').all()) { const buf = row.blob; if (buf && buf.byteLength === qvec.length * 4) vecById.set(row.chunk_id, blobToVec(buf)); } } const scored = []; for (const row of candidates) { const rel = path.relative(root, row.path).split(path.sep).join('/'); if (globRe && !globRe.test(rel)) continue; const rank = rankById.get(row.id) || 0; let cos = 0; if (qvec) { let v; if (vecById) { v = vecById.get(row.id); } else { const vr = db.prepare('SELECT blob FROM vectors WHERE chunk_id = ?').get(row.id); if (vr && vr.blob && vr.blob.byteLength === qvec.length * 4) v = blobToVec(vr.blob); } if (v) cos = cosine(qvec, v); } const score = FUSE_KEYWORD * rank + FUSE_VECTOR * cos; scored.push({ id: row.id, path: rel, heading: row.heading || '', start_line: row.start_line, end_line: row.end_line, score, snippet: snippetOf(row.text), _text: row.text, }); } scored.sort((a, b) => b.score - a.score || a.id - b.id); return scored.slice(0, k).map(({ _text, ...hit }) => ({ ...hit, text: _text })); } // --------------------------------------------------------------------------- // Status + get // --------------------------------------------------------------------------- function statusOf(db) { const files = db.prepare('SELECT COUNT(*) AS n FROM files').get().n; const chunks = db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n; const lastRaw = metaGet(db, 'last_index_at'); const root = metaGet(db, 'root') || DEFAULT_ROOT; const mode = metaGet(db, 'embedding_mode') || (files ? 'local' : 'unindexed'); const dims = metaGet(db, 'dims'); let stale = 0; let staleNote = ''; const skipCounts = { skipped_name: 0 }; let skippedExt = 0; if (files > 0 && fs.existsSync(root)) { const existing = new Map( db.prepare('SELECT id, path, hash FROM files').all().map((r) => [r.path, r]) ); const disk = walk(root, skipCounts); const diskPaths = new Set(); for (const f of disk) { diskPaths.add(f.abs); const ext = path.extname(f.rel).slice(1).toLowerCase(); if (EXT_SKIPS.has(ext)) { skippedExt++; continue; } // never indexed: not stale const rec = existing.get(f.abs); if (!rec) { stale++; continue; } try { const loaded = loadFile(f.abs, f.rel); if (loaded.skip || sha256(loaded.buf) !== rec.hash) stale++; } catch (e) { stale++; } } for (const p of existing.keys()) { if (!diskPaths.has(p)) stale++; } } else if (files > 0) { staleNote = `root missing on disk: ${root}`; } return { db: expandTilde(process.env.RAG_DB || DEFAULT_DB), root, files, chunks, last_index_at: lastRaw ? nowIso(Number(lastRaw)) : null, stale_files: stale, stale_note: staleNote, skipped_ext: skippedExt, skipped_name: skipCounts.skipped_name, embedding_mode: mode, dims: dims === null ? null : Number(dims), }; } function getChunk(db, id) { const row = db.prepare( 'SELECT c.id, c.file_id, c.ordinal, c.heading, c.start_line, c.end_line, c.text, f.path ' + 'FROM chunks c JOIN files f ON f.id = c.file_id WHERE c.id = ?' ).get(Number(id)); if (!row) return null; const root = metaGet(db, 'root') || DEFAULT_ROOT; return { id: row.id, path: path.relative(root, row.path).split(path.sep).join('/'), ordinal: row.ordinal, heading: row.heading || '', start_line: row.start_line, end_line: row.end_line, text: row.text, }; } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- function printIndexStats(s) { console.log( `index complete: files=${s.files} rechunked=${s.rechunked} purged=${s.purged} ` + `chunks=${s.chunks} skipped_binary=${s.skipped_binary} skipped_ext=${s.skipped_ext} ` + `skipped_name=${s.skipped_name} dims=${s.dims === null ? 'n/a' : s.dims} ` + `mode=${s.mode} elapsed=${s.ms}ms` ); console.log(`root: ${s.root}`); } function printHits(hits) { if (!hits.length) { console.log('no hits'); return; } hits.forEach((h, i) => { const head = h.heading ? ` ${h.heading}` : ''; console.log( `${i + 1}. ${h.path}:${h.start_line}-${h.end_line} (${h.score.toFixed(4)})${head}` ); console.log(` ${h.snippet}`); }); } function printStatus(s) { console.log(`db: ${s.db}`); console.log(`root: ${s.root}`); console.log(`files: ${s.files}`); console.log(`chunks: ${s.chunks}`); console.log(`last_index_at: ${s.last_index_at || 'never'}`); console.log(`stale_files: ${s.stale_files}${s.stale_note ? ` (${s.stale_note})` : ''}`); console.log(`skipped_ext: ${s.skipped_ext}`); console.log(`skipped_name: ${s.skipped_name}`); console.log(`embedding_mode: ${s.embedding_mode}`); console.log(`dims: ${s.dims === null ? 'n/a' : s.dims}`); } async function runCli(argv) { const cmd = argv[0]; const db = openDb(process.env.RAG_DB || DEFAULT_DB); try { if (cmd === 'index') { const root = argv[1] || metaGet(db, 'root') || DEFAULT_ROOT; printIndexStats(await indexRoot(db, root)); } else if (cmd === 'search') { const query = argv[1]; if (!query) throw new Error('usage: search [--k N] [--glob PATTERN]'); let k = 8; let glob = null; for (let i = 2; i < argv.length; i++) { if (argv[i] === '--k') { k = Number(argv[++i]); } else if (argv[i] === '--glob') { glob = argv[++i]; } else throw new Error(`unknown search option: ${argv[i]}`); } const hits = await search(db, query, { k, glob }); printHits(hits.map((h) => ({ ...h, snippet: h.snippet }))); } else if (cmd === 'status') { printStatus(statusOf(db)); } else if (cmd === 'get') { const id = Number(argv[1]); if (!Number.isFinite(id)) throw new Error('usage: get '); const c = getChunk(db, id); if (!c) throw new Error(`no chunk with id ${id}`); console.log( `chunk ${c.id}: ${c.path}:${c.start_line}-${c.end_line}` + `${c.heading ? ` heading: ${c.heading}` : ''}` ); console.log(c.text); } else { throw new Error( 'usage: node server.js index [root] | search [--k N] [--glob PATTERN] | status | get | (no args for MCP stdio)' ); } } finally { db.close(); } } // --------------------------------------------------------------------------- // MCP stdio mode // --------------------------------------------------------------------------- function toolDefs() { return [ { name: 'rag_search', description: 'Search the indexed workspace with keyword (FTS5) and vector ranking fused. ' + 'Returns ranked hits with path, line range, heading, score and a snippet.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search text (natural language or keywords)' }, k: { type: 'number', description: 'Maximum hits to return (default 8)' }, glob: { type: 'string', description: 'Glob filter on the relative path, e.g. "docs/**/*.md" or "api/*"' }, }, required: ['query'], }, }, { name: 'rag_index', description: 'Incremental reindex of the workspace root: hash-checks every file, re-chunks ' + 'changed files, purges deleted ones. Safe to call repeatedly.', inputSchema: { type: 'object', properties: { root: { type: 'string', description: 'Directory to index (default: the previously indexed root or /home/node/workspace)' }, }, }, }, { name: 'rag_status', description: 'Index status: database path, root, file and chunk counts, last index time, stale file count, embedding mode and dims.', inputSchema: { type: 'object', properties: {} }, }, { name: 'rag_get', description: 'Fetch one indexed chunk by id: full text plus path, heading and line range.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Chunk id (from rag_search hits)' }, }, required: ['id'], }, }, ]; } function textResult(text, structured) { const out = { content: [{ type: 'text', text }] }; if (structured !== undefined) out.structuredContent = structured; return out; } async function callTool(params) { const name = params.name; const args = params.arguments || {}; const db = openDb(process.env.RAG_DB || DEFAULT_DB); try { if (name === 'rag_search') { const query = args.query; if (typeof query !== 'string' || !query.trim()) { return textResult('error: query must be a non-empty string', { hits: [] }); } const hits = await search(db, query, { k: args.k, glob: args.glob }); const lines = hits.length ? hits.map((h, i) => { const head = h.heading ? ` ${h.heading}` : ''; return `${i + 1}. ${h.path}:${h.start_line}-${h.end_line} (${h.score.toFixed(4)})${head}\n ${h.snippet}`; }) : ['no hits']; const payload = hits.map(({ text, ...rest }) => rest); return textResult(lines.join('\n'), { hits: payload }); } if (name === 'rag_index') { const stats = await indexRoot(db, args.root || metaGet(db, 'root') || DEFAULT_ROOT); return textResult( `index complete: files=${stats.files} rechunked=${stats.rechunked} purged=${stats.purged} ` + `chunks=${stats.chunks} skipped_binary=${stats.skipped_binary} skipped_ext=${stats.skipped_ext} ` + `skipped_name=${stats.skipped_name} dims=${stats.dims === null ? 'n/a' : stats.dims} ` + `mode=${stats.mode} elapsed=${stats.ms}ms`, stats ); } if (name === 'rag_status') { const st = statusOf(db); return textResult(JSON.stringify(st, null, 2), st); } if (name === 'rag_get') { const c = getChunk(db, args.id); if (!c) return textResult(`error: no chunk with id ${args.id}`, { chunk: null }); const head = `chunk ${c.id}: ${c.path}:${c.start_line}-${c.end_line}${c.heading ? ` heading: ${c.heading}` : ''}`; return textResult(`${head}\n\n${c.text}`, { chunk: c }); } return { content: [{ type: 'text', text: `error: unknown tool: ${name}` }], isError: true }; } catch (e) { return { content: [{ type: 'text', text: `error: ${e.message}` }], isError: true }; } finally { db.close(); } } function writeMsg(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } async function handleMcpLine(line) { let msg; try { msg = JSON.parse(line); } catch (e) { writeMsg({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); return; } const { id, method, params } = msg || {}; if (!method) return; // a response or garbage without a method: ignore try { if (method === 'initialize') { writeMsg({ jsonrpc: '2.0', id, result: { protocolVersion: (params && params.protocolVersion) || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, }, }); } else if (method === 'ping') { writeMsg({ jsonrpc: '2.0', id, result: {} }); } else if (method === 'tools/list') { writeMsg({ jsonrpc: '2.0', id, result: { tools: toolDefs() } }); } else if (method === 'tools/call') { const result = await callTool(params || {}); writeMsg({ jsonrpc: '2.0', id, result }); } else if (method.startsWith('notifications/')) { // notifications (initialized, cancelled, progress): no response } else if (id === undefined || id === null) { // unknown notification: stay silent } else { writeMsg({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` }, }); } } catch (e) { writeMsg({ jsonrpc: '2.0', id, error: { code: -32603, message: String((e && e.message) || e) }, }); } } function startMcp() { let pending = ''; let chain = Promise.resolve(); // serialize line handling: responses keep request order process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { pending += chunk; let idx; while ((idx = pending.indexOf('\n')) >= 0) { const line = pending.slice(0, idx).trim(); pending = pending.slice(idx + 1); if (line) chain = chain.then(() => handleMcpLine(line)).catch((e) => logErr(e.message)); } }); process.stdin.on('end', () => process.exit(0)); process.stdin.resume(); } // --------------------------------------------------------------------------- // Entry // --------------------------------------------------------------------------- async function main() { const argv = process.argv.slice(2); if (argv.length === 0) { startMcp(); return; } await runCli(argv); } main().catch((e) => { logErr(e && e.stack ? e.stack : String(e)); process.exit(1); }); ================ File: workspace/agents/opencode/rag/test.sh ================ #!/usr/bin/env bash # rag/test.sh — self-contained test suite for the rag MCP server and CLI. # Covers: CLI index/search/glob/status/get, incremental reindex (change + # delete), Java method-boundary chunking, build-output and lockfile skips, # MCP stdio handshake, and the remote embeddings path via a local stub. # Zero npm dependencies: needs node, curl, sed, grep, coreutils. # All state lives in /tmp/rag-test-$$ and is removed on exit. set -u cd "$(dirname "$0")" # rag/ PASS=0 FAIL=0 WORK="/tmp/rag-test-$$" mkdir -p "$WORK" STUB_PID="" cleanup() { if [ -n "$STUB_PID" ]; then kill "$STUB_PID" 2>/dev/null || true fi rm -rf "$WORK" } trap cleanup EXIT pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } # Extract a key=value field from the "index complete:" line. field() { printf '%s\n' "$1" | sed -n 's/.*'"$2"'=\([0-9]*\).*/\1/p'; } # Fresh per-case workspace: pristine corpus copy plus its own database. new_case() { CASE="$WORK/$1" mkdir -p "$CASE" cp -r test/corpus "$CASE/corpus" DB="$CASE/rag.db" } idx() { RAG_DB="$DB" node server.js index "$CASE/corpus" 2>/dev/null; } srch() { RAG_DB="$DB" node server.js search "$@" 2>/dev/null; } echo "== syntax check ==" if node --check server.js && node --check test/upstream-embeddings-stub.js \ && node --check test/mcp-check.js && node --check test/java-check.js; then pass "syntax check (server.js, stub, mcp-check, java-check)" else fail "syntax check" echo "passed: $PASS failed: $FAIL" exit 1 fi # --- Case 1: CLI index reports 4 indexed files, binary skipped, chunks > 7 --- echo "== case 1: CLI index ==" new_case c1 OUT="$(idx)" FILES="$(field "$OUT" files)" CHUNKS="$(field "$OUT" chunks)" SKIPPED="$(field "$OUT" skipped_binary)" if [ "$FILES" = "4" ] && [ "$SKIPPED" = "1" ] && [ -n "$CHUNKS" ] && [ "$CHUNKS" -gt 7 ]; then pass "case 1: index reports files=4, skipped_binary=1, chunks=$CHUNKS (>7)" else fail "case 1: index stats wrong (files=$FILES skipped=$SKIPPED chunks=$CHUNKS)" fi # --- Case 2: search finds the unique phrase, top hit is getting-started.md --- echo "== case 2: CLI search unique phrase ==" OUT="$(srch "quantum marmalade")" TOP="$(printf '%s\n' "$OUT" | head -1)" if printf '%s' "$TOP" | grep -q "getting-started.md" \ && printf '%s' "$OUT" | grep -q "quantum marmalade"; then pass "case 2: top hit is getting-started.md and carries the phrase" else fail "case 2: unexpected result (top: $TOP)" fi # --- Case 3: --glob restricts hits to api/users.md --------------------------- echo "== case 3: CLI search with glob ==" OUT="$(srch "users api rename" --glob "api/*")" if printf '%s' "$OUT" | grep -q "api/users.md" \ && ! printf '%s' "$OUT" | grep -q "getting-started.md" \ && ! printf '%s' "$OUT" | grep -q "format.js"; then pass "case 3: glob api/* limits hits to api/users.md" else fail "case 3: glob filter leaked other files" fi # --- Case 4: re-index unchanged corpus is a no-op ---------------------------- echo "== case 4: incremental reindex no-op ==" OUT="$(idx)" RECH="$(field "$OUT" rechunked)" CHUNKS2="$(field "$OUT" chunks)" FILES2="$(field "$OUT" files)" if [ "$RECH" = "0" ] && [ "$CHUNKS2" = "$CHUNKS" ] && [ "$FILES2" = "4" ]; then pass "case 4: reindex unchanged (rechunked=0, chunks=$CHUNKS2 stable)" else fail "case 4: reindex not a no-op (rechunked=$RECH chunks=$CHUNKS2 files=$FILES2)" fi # --- Case 5: mutate one file, delete another, reindex ------------------------ echo "== case 5: incremental change + delete ==" new_case c5 idx >/dev/null printf '\n// mutation marker for the reindex test\nfunction mutated() { return 42; }\n' \ >> "$CASE/corpus/utils/format.js" rm "$CASE/corpus/api/users.md" OUT="$(idx)" RECH="$(field "$OUT" rechunked)" PURGED="$(field "$OUT" purged)" ST="$(RAG_DB="$DB" node server.js status 2>/dev/null)" FILES3="$(printf '%s\n' "$ST" | sed -n 's/^files: \([0-9]*\)$/\1/p')" GONE="$(srch "rename a user account")" if [ "$RECH" = "1" ] && [ "$PURGED" = "1" ] && [ "$FILES3" = "3" ] \ && ! printf '%s' "$GONE" | grep -q "api/users.md"; then pass "case 5: changed file re-chunked, deleted file purged (files=$FILES3)" else fail "case 5: incremental state wrong (rechunked=$RECH purged=$PURGED files=$FILES3)" fi # --- Case 6: MCP stdio handshake over JSON-RPC lines -------------------------- echo "== case 6: MCP handshake ==" new_case c6 idx >/dev/null REQ="$WORK/reqs.jsonl" RESP="$WORK/resp.jsonl" cat > "$REQ" <<'EOF' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"rag-test","version":"0.0.0"}}} {"jsonrpc":"2.0","method":"notifications/initialized"} {"jsonrpc":"2.0","id":2,"method":"tools/list"} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"rag_search","arguments":{"query":"quantum marmalade"}}} EOF RAG_DB="$DB" node server.js < "$REQ" > "$RESP" 2>/dev/null if node test/mcp-check.js "$RESP"; then pass "case 6: MCP handshake (initialize, tools/list x4, tools/call hits)" else fail "case 6: MCP handshake assertions failed" fi # --- Case 7: remote embeddings via stub (dims 8, bearer key) ------------------ echo "== case 7: remote embeddings ==" node test/upstream-embeddings-stub.js >/dev/null 2>&1 & STUB_PID=$! READY=0 for _ in $(seq 1 50); do if curl -sf http://127.0.0.1:4599/healthz >/dev/null 2>&1; then READY=1; break; fi sleep 0.1 done if [ "$READY" = "1" ]; then new_case c7 OUT="$(RAG_EMBEDDINGS_URL=http://127.0.0.1:4599/v1/embeddings \ RAG_EMBEDDINGS_MODEL=test-embed RAG_EMBEDDINGS_KEY=testkey \ RAG_DB="$DB" node server.js index "$CASE/corpus" 2>/dev/null)" ST="$(RAG_EMBEDDINGS_URL=http://127.0.0.1:4599/v1/embeddings \ RAG_EMBEDDINGS_MODEL=test-embed RAG_EMBEDDINGS_KEY=testkey \ RAG_DB="$DB" node server.js status 2>/dev/null)" AUTH="$(curl -s http://127.0.0.1:4599/last-auth)" OUT2="$(RAG_EMBEDDINGS_URL=http://127.0.0.1:4599/v1/embeddings \ RAG_EMBEDDINGS_MODEL=test-embed RAG_EMBEDDINGS_KEY=testkey \ RAG_DB="$DB" node server.js search "quantum marmalade" 2>/dev/null)" TOP="$(printf '%s\n' "$OUT2" | head -1)" if printf '%s' "$OUT" | grep -q "mode=remote" \ && printf '%s\n' "$ST" | grep -q "^embedding_mode: remote$" \ && printf '%s\n' "$ST" | grep -q "^dims: 8$" \ && [ "$AUTH" = "Bearer testkey" ] \ && printf '%s' "$TOP" | grep -q "getting-started.md"; then pass "case 7: remote mode indexed (dims=8), bearer key sent, search works" else fail "case 7: remote mode wrong (out=$OUT auth=$AUTH top=$TOP)" fi else fail "case 7: embeddings stub did not become ready" fi kill "$STUB_PID" 2>/dev/null || true STUB_PID="" # --- Case 8: rag_get returns the full chunk text ------------------------------- echo "== case 8: CLI get ==" new_case c8 idx >/dev/null OUT="$(RAG_DB="$DB" node server.js get 1 2>/dev/null)" LINES="$(printf '%s\n' "$OUT" | wc -l)" if printf '%s\n' "$OUT" | grep -q "^chunk 1:" && [ "$LINES" -gt 1 ]; then pass "case 8: get 1 returns metadata header and text ($LINES lines)" else fail "case 8: get 1 returned nothing usable" fi # --- Case 9: Java chunking (method boundaries, headings, hard split) ---------- echo "== case 9: Java chunking ==" new_case c9 idx >/dev/null OUT="$(srch "marmaladeDeduplication")" TOP="$(printf '%s\n' "$OUT" | head -1)" CID="" GOT="" if node test/java-check.js "$DB" > "$WORK/c9.chk" 2>/dev/null \ && printf '%s' "$TOP" | grep -q "UserService.java"; then CID="$(sed -n 's/^CHUNK_ID=//p' "$WORK/c9.chk")" GOT="$(RAG_DB="$DB" node server.js get "$CID" 2>/dev/null | head -1)" fi if printf '%s' "$TOP" | grep -q "UserService.java" \ && grep -q ": ok" "$WORK/c9.chk" && ! grep -q "FAIL" "$WORK/c9.chk" \ && printf '%s' "$GOT" | grep -q "processBatch"; then pass "case 9: java chunking (top hit UserService.java, get $CID: $GOT)" else fail "case 9: java chunking wrong (top: $TOP get: $GOT)" cat "$WORK/c9.chk" 2>/dev/null || true fi # --- Case 10: build-output dirs and lockfiles are skipped --------------------- echo "== case 10: build dir + lockfile skips ==" new_case c10 OUT="$(idx)" ST="$(RAG_DB="$DB" node server.js status 2>/dev/null)" FILES4="$(printf '%s\n' "$ST" | sed -n 's/^files: \([0-9]*\)$/\1/p')" NOJUNK="$(srch "compiled leftover")" if [ "$FILES4" = "4" ] \ && printf '%s\n' "$OUT" | grep -q "skipped_name=1" \ && printf '%s\n' "$ST" | grep -q "^skipped_name: 1$" \ && ! printf '%s' "$NOJUNK" | grep -q "junk.txt"; then pass "case 10: target/ and package-lock.json skipped (files=$FILES4, no junk.txt hits)" else fail "case 10: skip accounting wrong (files=$FILES4 out=$OUT junk=$NOJUNK)" fi echo "----" echo "passed: $PASS failed: $FAIL" if [ "$FAIL" -eq 0 ]; then exit 0 fi exit 1 ================ File: workspace/agents/opencode/session/state.example.md ================ # STATE — Session Log (Example) > Worked example of the `engine/contract/state.template.md` structure, filled with plausible values. > Copy this structure to `session/state.md` (local state — never committed) and let > `scripts/snapshot-contract.sh` append transition rows. ## Current Focus State: **EXECUTE** — implementing plan task 3 of 5 (adapter layer + mapper tests). Developer shard A on `*InPort.java`/`*Service.java`, shard B on `*Adapter.java`; files provably disjoint. ## Known Blockers None. ## Transition Log | Timestamp | Session | State | Score | Hash | Summary | |-----------|---------|-------|-------|------|---------| | 2026-11-01T09:14:02Z | sess-20261101-a1b2 | INIT | — | 3f9c1a2 | Session initialised from contract.template.json | | 2026-11-01T09:20:37Z | sess-20261101-a1b2 | PLAN | — | 7d21e8b | Plan produced by @system-analyst (5 tasks, 2 parallel-eligible) | | 2026-11-01T09:24:10Z | sess-20261101-a1b2 | PLAN_SCORED | 84 | 91ac04f | Plan accepted (score ≥ 70), spec gate passed | | 2026-11-01T09:31:55Z | sess-20261101-a1b2 | EXECUTE | — | c48b771 | Developer shards A/B dispatched (max_parallel_agents: 2) | Append-only. Each transition row is written by `scripts/snapshot-contract.sh`. ================ File: workspace/agents/opencode/.dockerignore ================ .git .gitignore .idea session .session pre-install .env* node_modules docker-compose.example.yaml ================ File: workspace/agents/opencode/.env.template ================ # .env.template for docker compose (copy to .env and fill in) # All values are optional: the compose files carry working defaults. # Required for OpenCode auth (compose default is a placeholder) OPENCODE_API_KEY=REPLACE_ME # Model provider (compose defaults exist for all three) SUMOPOD_API_KEY= SUMOPOD_BASE_URL= SUMOPOD_BASE_MODEL= # Context7 docs MCP (compose default is a placeholder) CONTEXT7_API_KEY= # Optional Firecrawl key for the vendored @firecrawl/opencode plugin. # Leave empty: firecrawl_developer_search works keyless and CLI-free. # firecrawl_alexandria and the firecrawl-cli skills require it (or OAuth). FIRECRAWL_API_KEY= # Key for OpenCode's own auth (pairing screen / HTTP basic auth on the opencode server). Leave empty to disable. OPENCODE_SERVER_PASSWORD= # Optional real embeddings endpoint for the rag MCP server. All three must be # set to leave local hashed-vector mode; local mode is deterministic, needs no # network, and works fully offline (the pre-install store needs nothing for it). # RAG_EMBEDDINGS_URL points at an OpenAI-compatible /v1/embeddings endpoint, # RAG_EMBEDDINGS_MODEL is the model id, RAG_EMBEDDINGS_KEY its bearer key. RAG_EMBEDDINGS_URL= RAG_EMBEDDINGS_MODEL= RAG_EMBEDDINGS_KEY= ================ File: workspace/agents/opencode/.gitattributes ================ *.sh text eol=lf Dockerfile text eol=lf *.json text eol=lf *.yml text eol=lf *.yaml text eol=lf ================ File: workspace/agents/opencode/.gitignore ================ .idea/ .session/ session/* !session/state.example.md pre-install/** !pre-install/**/ !pre-install/**/.gitkeep ================ File: workspace/agents/opencode/docker-compose.example.yaml ================ name: opencode services: opencode: container_name: opencode image: opencode:local # tag applied to the build below; scheduler service reuses it labels: bpg.group: infra bpg.type: agents build: context: . additional_contexts: preinstall: ./pre-install args: OPENCODE_VERSION: "2.2.5" # pinned for reproducible builds OPENCODE_ARCH: "arm64" # matches pre-install store tarballs; x64 builds override GRAPHIFY_VERSION: "0.9.100" LEAN_CTX_VERSION: "3.12.0" CONTEXT7_VERSION: "4.4.4" MCP_VERSION: "2.3.0" MCP_SERVER_FETCH_VERSION: "2026.10.28" init: true mem_limit: 3g cpus: 2 restart: unless-stopped ports: - "127.0.0.1:4096:4096" environment: LANG: C.UTF-8 LC_ALL: C.UTF-8 TERM: xterm-256color OPENCODE_API_KEY: ${OPENCODE_API_KEY:-REPLACE_ME} OPENCODE_AUTH_REQUIRED: "true" OPENCODE_SERVER_PASSWORD: ${OPENCODE_SERVER_PASSWORD:-} OPENCODE_CONFIG: /home/node/.config/opencode/opencode.json SUMOPOD_API_KEY: ${SUMOPOD_API_KEY:-REPLACE_ME} SUMOPOD_BASE_URL: ${SUMOPOD_BASE_URL:-REPLACE_ME} SUMOPOD_BASE_MODEL: ${SUMOPOD_BASE_MODEL:-REPLACE_ME} SUMOPOD_BASE_MODEL_NAME: ${SUMOPOD_BASE_MODEL_NAME:-REPLACE_ME} CONTEXT7_API_KEY: ${CONTEXT7_API_KEY:-REPLACE_ME} # Optional: keyless developer search works without it; alexandria + firecrawl-cli skills need it FIRECRAWL_API_KEY: ${FIRECRAWL_API_KEY:-} # Optional rag embeddings upgrade; empty keeps offline hashed-vector mode RAG_EMBEDDINGS_URL: ${RAG_EMBEDDINGS_URL:-} RAG_EMBEDDINGS_MODEL: ${RAG_EMBEDDINGS_MODEL:-} RAG_EMBEDDINGS_KEY: ${RAG_EMBEDDINGS_KEY:-} # Graphify disabled but config preserved GRAPHIFY_ROOT: /home/node/workspace GRAPHIFY_BUILD: if-missing GRAPHIFY_TIMEOUT: "900" volumes: - .:/home/node/workspace - ./config/opencode.json:/home/node/.config/opencode/opencode.json:ro - ./config/opencode-model-fallback.json:/home/node/.config/opencode/opencode-model-fallback.json:ro - ./rag:/opt/rag:ro - opencode-data:/home/node/.local/share/opencode - /var/run/docker.sock:/var/run/docker.sock group_add: - "${DOCKER_GID:-101}" opencode-preinstall: image: node:22-bookworm-slim profiles: ["tools"] user: root volumes: - .:/repo - ./pre-install:/repo/pre-install # explicit store mount (project level) working_dir: /repo command: >- sh -c "apt-get update -qq && apt-get install -y -qq --no-install-recommends curl ca-certificates python3 python3-pip git && sh /repo/populate-preinstall.sh" opencode-scheduler: container_name: opencode-scheduler image: opencode:local profiles: ["scheduler"] depends_on: [opencode] restart: unless-stopped init: true working_dir: /home/node/workspace/engine # run_task invokes scripts/... relative to CWD volumes: - .:/home/node/workspace environment: GRAPHIFY_ROOT: /home/node/workspace GRAPHIFY_BUILD: if-missing command: >- sh -c "scripts/scheduled-runner.sh --task drift-detect --schedule daily --pid-file /tmp/scheduled-runner.pid && exec tail -F scheduled-runner.log" volumes: opencode-data: ================ File: workspace/agents/opencode/Dockerfile ================ FROM node:22-bookworm-slim ARG OPENCODE_VERSION=2.2.5 # Platform package suffix used by the offline store tarball glob # (@opencode+cli-linux- - .tgz). Override for x64 builds: # OPENCODE_ARCH=x64 (or x64-baseline depending on store contents). ARG OPENCODE_ARCH=arm64 ARG GRAPHIFY_VERSION=0.9.100 ARG LEAN_CTX_VERSION=3.12.0 ARG CONTEXT7_VERSION=4.4.4 ARG MCP_MEMORY_VERSION=2.0.1 ARG MCP_EVERYTHING_VERSION=2.1.0 ARG MCP_VERSION=2.3.0 ARG MCP_SERVER_FETCH_VERSION=2026.10.28 # Vendored first-party plugin (@firecrawl/opencode) + its runtime dep ARG ZOD_VERSION=4.1.8 ARG FIRECRAWL_CLI_VERSION=1.26.24 # Corporate CA certificates. # Place PEM certificates with the .crt extension under certs/. COPY certs/ /usr/local/share/ca-certificates/ # Base packages. The optional `preinstall` additional build context (/pi, see # docker-compose files) pre-seeds cached .deb files so repeat builds skip most # downloads; the apt cache mounts keep downloaded state out of the image layer. RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ cp /pi/apt/*.deb /var/cache/apt/archives/ 2>/dev/null || true; \ apt-get update; \ apt-get install -y --no-install-recommends \ git \ curl \ sudo \ iproute2 \ jq \ ripgrep \ ca-certificates \ python3 \ pipx \ python3-venv \ coreutils \ procps; \ update-ca-certificates; \ git config --system --add safe.directory '*' # Configure Node.js and Python to trust the corporate CA. ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt \ SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ PIP_CERT=/etc/ssl/certs/ca-certificates.crt # Locale + terminal defaults so the TUI and ncurses apps render correctly in # `docker exec -it` sessions (which inherit the container env, not the host's). ENV LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ TERM=xterm-256color # Install OpenCode and the Context7 MCP server. # Context7 is baked (not npx) so library docs lookup works on # offline / CA-restricted corporate networks without a runtime download. # Tarball-first from the offline store (/pi/npm), network as fallback. RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ if ls /pi/npm/@opencode+cli-*.tgz >/dev/null 2>&1 \ && ls /pi/npm/@opencode+cli-linux-${OPENCODE_ARCH}-*.tgz >/dev/null 2>&1; then \ npm install -g --offline --no-audit --no-fund \ /pi/npm/@opencode+cli-*.tgz /pi/npm/@opencode+cli-linux-${OPENCODE_ARCH}-*.tgz \ || npm install -g --no-audit --no-fund \ /pi/npm/@opencode+cli-*.tgz /pi/npm/@opencode+cli-linux-${OPENCODE_ARCH}-*.tgz; \ else \ npm install -g --no-audit --no-fund "@opencode/cli@${OPENCODE_VERSION}"; \ fi; \ if ls /pi/npm/@upstash+context7-mcp-*.tgz >/dev/null 2>&1; then \ npm install -g --offline --no-audit --no-fund /pi/npm/@upstash+context7-mcp-*.tgz \ || npm install -g --no-audit --no-fund /pi/npm/@upstash+context7-mcp-*.tgz; \ else \ npm install -g --no-audit --no-fund "@upstash/context7-mcp@${CONTEXT7_VERSION}"; \ fi; \ opencode --version; \ command -v context7-mcp # lean-ctx binary: from the offline store when present, else upstream installer. RUN --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ store_ok=false; \ if [ -f /pi/bin/lean-ctx ]; then \ install -m 0755 /pi/bin/lean-ctx /usr/local/bin/lean-ctx; \ lean-ctx --version >/dev/null 2>&1 && store_ok=true || rm -f /usr/local/bin/lean-ctx; \ fi; \ if [ "$store_ok" != true ]; then \ echo "store lean-ctx missing or wrong-arch — fetching from network"; \ curl -fsSL \ "https://raw.githubusercontent.com/yvgude/lean-ctx/v${LEAN_CTX_VERSION}/install.sh" \ -o /tmp/leanctx-install.sh; \ chmod +x /tmp/leanctx-install.sh; \ LEAN_CTX_NO_ONBOARD=1 sh /tmp/leanctx-install.sh --download; \ install -m 0755 /root/.local/bin/lean-ctx /usr/local/bin/lean-ctx; \ rm -rf /root/.local /tmp/leanctx-install.sh; \ fi; \ lean-ctx --version # Docker CLI (client only - talks to the host daemon via the compose socket mount). ARG TARGETARCH ARG DOCKER_CLI_VERSION=27.3.1 RUN case "${TARGETARCH}" in \ arm64) DC_ARCH=aarch64 ;; \ amd64) DC_ARCH=x86_64 ;; \ *) DC_ARCH=x86_64 ;; \ esac \ && curl -fsSL "https://download.docker.com/linux/static/stable/${DC_ARCH}/docker-${DOCKER_CLI_VERSION}.tgz" -o /tmp/docker-cli.tgz \ && tar -xzf /tmp/docker-cli.tgz -C /tmp \ && install -m 0755 /tmp/docker/docker /usr/local/bin/docker \ && rm -rf /tmp/docker /tmp/docker-cli.tgz # Install Graphify and its MCP server (wheel-first from the offline store). ENV PIPX_HOME=/opt/pipx \ PIPX_BIN_DIR=/usr/local/bin RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ if ls /pi/pip/graphifyy-*.whl >/dev/null 2>&1; then \ PIP_FIND_LINKS=/pi/pip PIP_NO_INDEX=true pipx install /pi/pip/graphifyy-*.whl; \ PIP_FIND_LINKS=/pi/pip PIP_NO_INDEX=true pipx inject graphifyy /pi/pip/mcp-*.whl; \ else \ pipx install "graphifyy[mcp]==${GRAPHIFY_VERSION}"; \ pipx inject graphifyy "mcp==${MCP_VERSION}"; \ fi; \ graphify --help >/dev/null; \ command -v graphify-mcp # Memory + everything MCP servers and the fetch MCP server, # tarball/wheel-first from the offline store, network as fallback. RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=type=cache,target=/root/.cache/pip,sharing=locked \ --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ if ls /pi/npm/@modelcontextprotocol+server-memory-*.tgz >/dev/null 2>&1; then \ npm install -g --offline --no-audit --no-fund /pi/npm/@modelcontextprotocol+server-memory-*.tgz \ || npm install -g --no-audit --no-fund /pi/npm/@modelcontextprotocol+server-memory-*.tgz; \ else \ npm install -g --no-audit --no-fund "@modelcontextprotocol/server-memory@${MCP_MEMORY_VERSION}"; \ fi; \ if ls /pi/npm/@modelcontextprotocol+server-everything-*.tgz >/dev/null 2>&1; then \ npm install -g --offline --no-audit --no-fund /pi/npm/@modelcontextprotocol+server-everything-*.tgz \ || npm install -g --no-audit --no-fund /pi/npm/@modelcontextprotocol+server-everything-*.tgz; \ else \ npm install -g --no-audit --no-fund "@modelcontextprotocol/server-everything@${MCP_EVERYTHING_VERSION}"; \ fi; \ if ls /pi/pip/mcp_server_fetch-*.whl >/dev/null 2>&1; then \ PIP_FIND_LINKS=/pi/pip PIP_NO_INDEX=true pipx install /pi/pip/mcp_server_fetch-*.whl; \ else \ pipx install "mcp-server-fetch==${MCP_SERVER_FETCH_VERSION}"; \ fi; \ # Bake readabilipy's Readability.js deps: without node_modules in the # package dir, the fetch server shells out to `npm install` per request, # which fails air-gapped. Small tree; network-at-build is acceptable # (same trade-off as the graphify/firecrawl fallbacks above). JS_DIR="/opt/pipx/venvs/mcp-server-fetch/lib/python3.11/site-packages/readabilipy/javascript"; \ npm install --prefix "$JS_DIR" --no-audit --no-fund; \ test -d "$JS_DIR/node_modules/@mozilla/readability" # Prepare OpenCode configuration and persistent-data directories. RUN mkdir -p \ /home/node/.config/opencode \ /home/node/.local/share/opencode \ /home/node/workspace \ && chown -R node:node /home/node # Web uploads stage into /tmp/opencode/uploads — must be writable by the node # user (root runtime processes can otherwise create it root-owned first). RUN mkdir -p /tmp/opencode && chown node:node /tmp/opencode # lean-ctx shell allowlist extension: merged on top of the built-in defaults # (shell_allowlist_extra per lean-ctx docs config-keys reference) so agent sessions # can run docker/sh/bash/sudo/ss through ctx_shell without refusals. Baked at build # time because lean-ctx deliberately blocks agents from changing their own policy # at runtime — the user approved this expansion explicitly. ARG LEAN_CTX_CONFIG_DIR=/home/node/.config/lean-ctx RUN mkdir -p ${LEAN_CTX_CONFIG_DIR} \ && printf 'shell_allowlist_extra = ["docker", "sh", "bash", "sudo", "ss"]\n' \ > ${LEAN_CTX_CONFIG_DIR}/config.toml \ && chown node:node ${LEAN_CTX_CONFIG_DIR}/config.toml # Install the entrypoint and normalize Windows CRLF line endings. # (Placed before COPY engine/ so engine edits don't invalidate these layers.) COPY entrypoint.sh /usr/local/bin/opencode-entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/opencode-entrypoint.sh \ && chmod 755 /usr/local/bin/opencode-entrypoint.sh USER node # NOTE: the removed custom plugins (model-fallback, dcp, websearch-cited) # targeted the V1 plugin API and V2 also refuses the old file:// # plugin entries ("configured plugin path must be a directory"), so they # silently never loaded. Remove this note once they are ported to the V2 # @opencode/plugin API and re-added to config/opencode.json. The first-party # @firecrawl/opencode plugin is separate: it is V2-native and vendored # offline at engine/plugins/opencode-firecrawl (see the RUN block below). # Copy the shared workflow engine. (Kept LAST: engine edits only invalidate # the layers below, not the package installs above.) ENV ENGINE_ROOT=/opt/engine # Engine transform needs root: removes root-owned files, chowns, sets exec bits USER root COPY engine/ /opt/engine/ # RAG MCP server: zero-dependency node script (rag/server.js -> /opt/rag). # Image-baked as the base; compose additionally bind-mounts ./rag:/opt/rag:ro # so server edits activate without a rebuild (the mount wins when present). COPY rag/ /opt/rag/ # Vendored @firecrawl/opencode plugin: install its single runtime dependency # (zod) from the offline store into the plugin dir, and firecrawl-cli # (binary: firecrawl) globally. Tarball-first from /pi/npm, network fallback, # same style as the npm blocks above. Runs before the engine transform below # so the final chown covers the installed node_modules. RUN --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=from=preinstall,src=/,dst=/pi,ro \ set -eux; \ rm -rf /tmp/zod-install; \ mkdir -p /tmp/zod-install; \ if ls /pi/npm/zod-*.tgz >/dev/null 2>&1; then \ npm install --no-save --no-package-lock --offline --no-audit --no-fund \ --prefix /tmp/zod-install /pi/npm/zod-*.tgz \ || npm install --no-save --no-package-lock --no-audit --no-fund \ --prefix /tmp/zod-install /pi/npm/zod-*.tgz; \ else \ npm install --no-save --no-package-lock --no-audit --no-fund \ --prefix /tmp/zod-install "zod@${ZOD_VERSION}"; \ fi; \ rm -rf /opt/engine/plugins/opencode-firecrawl/node_modules; \ mv /tmp/zod-install/node_modules /opt/engine/plugins/opencode-firecrawl/node_modules; \ rm -rf /tmp/zod-install; \ if ls /pi/npm/firecrawl-cli-*.tgz >/dev/null 2>&1; then \ npm install -g --offline --no-audit --no-fund /pi/npm/firecrawl-cli-*.tgz \ || npm install -g --no-audit --no-fund /pi/npm/firecrawl-cli-*.tgz; \ else \ npm install -g --no-audit --no-fund "firecrawl-cli@${FIRECRAWL_CLI_VERSION}"; \ fi; \ test -d /opt/engine/plugins/opencode-firecrawl; \ test -d /opt/engine/plugins/opencode-firecrawl/node_modules/zod; \ test ! -d /opt/engine/plugins/opencode-firecrawl/node_modules/@opencode RUN set -eux; \ rm -f /opt/engine/.env /opt/engine/.env.template; \ find /opt/engine -type f \( \ -name '*.sh' \ -o -name '*.md' \ -o -name '*.json' \ -o -name '*.js' \ -o -name '*.mjs' \ -o -name '*.ts' \ -o -name '*.py' \ \) -exec sed -i 's/\r$//' {} +; \ if [ -d /opt/engine/.githooks ]; then \ find /opt/engine/.githooks \ -type f \ -exec sed -i 's/\r$//' {} + \ -exec chmod a+x {} +; \ fi; \ find /opt/engine -name '*.md' -not -path '/opt/engine/plugins/*' \ -exec sed -E -i \ -e 's#\.opencode/(agents|skills|rules|usage|contract)/#/opt/engine/\1/#g' \ {} +; \ python3 /opt/engine/scripts/normalize-doc-paths.py /opt/engine /opt/engine; \ find /opt/engine -name '*.sh' -exec chmod a+x {} +; \ chown -R node:node /opt/engine; \ ln -sfn /opt/engine/agent.md \ /home/node/.config/opencode/agent.md; \ ln -sfn /opt/engine/agents \ /home/node/.config/opencode/agents; \ ln -sfn /opt/engine/skills \ /home/node/.config/opencode/skills; \ ln -sfn /opt/engine/commands \ /home/node/.config/opencode/commands; \ ln -sfn /opt/engine/rules \ /home/node/.config/opencode/rules; \ ln -sfn /opt/engine/contract \ /home/node/.config/opencode/contract; \ ln -sfn /opt/engine/usage \ /home/node/.config/opencode/usage; \ ln -sfn /opt/engine/doc \ /home/node/.config/opencode/doc # Drop back to the runtime user; nothing below needs root. USER node WORKDIR /home/node/workspace ENTRYPOINT ["/usr/local/bin/opencode-entrypoint.sh"] # entrypoint.sh probes and prefers `opencode serve`; CMD kept in sync with # that path and serves as the bare fallback. CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"] ================ File: workspace/agents/opencode/entrypoint.sh ================ #!/bin/sh set -eu # Web uploads stage into /tmp/opencode/uploads. The image bakes /tmp/opencode # node-owned; this guard re-creates it if absent (no-op if root-owned). mkdir -p /tmp/opencode/uploads 2>/dev/null || true # ----------------------------------------------------------------------------- # OpenCode authentication # ----------------------------------------------------------------------------- OPENCODE_AUTH_REQUIRED="${OPENCODE_AUTH_REQUIRED:-true}" OPENCODE_AUTH_DIR="${OPENCODE_AUTH_DIR:-$HOME/.local/share/opencode}" OPENCODE_AUTH_FILE="${OPENCODE_AUTH_FILE:-$OPENCODE_AUTH_DIR/auth.json}" configure_opencode_auth() { if [ -z "${OPENCODE_API_KEY:-}" ]; then if [ "$OPENCODE_AUTH_REQUIRED" = "true" ]; then echo "[entrypoint] ERROR: OPENCODE_API_KEY is required" >&2 exit 1 fi echo "[entrypoint] OPENCODE_API_KEY is not set; skipping authentication" return fi if ! command -v node >/dev/null 2>&1; then echo "[entrypoint] ERROR: node executable not found" >&2 exit 1 fi mkdir -p "$OPENCODE_AUTH_DIR" chmod 700 "$OPENCODE_AUTH_DIR" export OPENCODE_AUTH_FILE node <<'NODE' const fs = require("fs"); const path = require("path"); const authFile = process.env.OPENCODE_AUTH_FILE; const apiKey = process.env.OPENCODE_API_KEY; if (!authFile) { console.error("[entrypoint] ERROR: OPENCODE_AUTH_FILE is missing"); process.exit(1); } if (!apiKey) { console.error("[entrypoint] ERROR: OPENCODE_API_KEY is missing"); process.exit(1); } let auth = {}; if (fs.existsSync(authFile)) { try { const content = fs.readFileSync(authFile, "utf8").trim(); if (content) { auth = JSON.parse(content); } } catch (error) { console.error( `[entrypoint] ERROR: cannot parse ${authFile}: ${error.message}` ); process.exit(1); } } if ( auth.opencode && auth.opencode.type === "api" && auth.opencode.key === apiKey ) { console.log("[entrypoint] OpenCode authentication already configured"); process.exit(0); } auth.opencode = { type: "api", key: apiKey }; fs.mkdirSync(path.dirname(authFile), { recursive: true, mode: 0o700 }); const temporaryFile = `${authFile}.tmp`; fs.writeFileSync( temporaryFile, `${JSON.stringify(auth, null, 2)}\n`, { encoding: "utf8", mode: 0o600 } ); fs.renameSync(temporaryFile, authFile); fs.chmodSync(authFile, 0o600); console.log("[entrypoint] OpenCode authentication configured"); NODE } configure_opencode_auth # OpenCode reads the credential from auth.json. # Remove the original token from the environment inherited by plugins and MCPs. unset OPENCODE_API_KEY # ----------------------------------------------------------------------------- # Graphify initialization # ----------------------------------------------------------------------------- GRAPHIFY_ROOT="${GRAPHIFY_ROOT:-/home/node/workspace}" GRAPHIFY_GRAPH="${GRAPHIFY_GRAPH:-$GRAPHIFY_ROOT/graphify-out/graph.json}" GRAPHIFY_BUILD="${GRAPHIFY_BUILD:-if-missing}" GRAPHIFY_TIMEOUT="${GRAPHIFY_TIMEOUT:-900}" case "$GRAPHIFY_BUILD" in always) build=1 ;; never) build=0 ;; if-missing) if [ -f "$GRAPHIFY_GRAPH" ]; then build=0 else build=1 fi ;; *) echo "[entrypoint] ERROR: invalid GRAPHIFY_BUILD=$GRAPHIFY_BUILD" >&2 echo "[entrypoint] Valid values: if-missing, always, never" >&2 exit 1 ;; esac if [ "$build" = "1" ]; then if [ ! -d "$GRAPHIFY_ROOT" ]; then echo "[entrypoint] WARNING: GRAPHIFY_ROOT not found: $GRAPHIFY_ROOT" >&2 echo "[entrypoint] continuing without building Graphify" >&2 elif ! command -v graphify >/dev/null 2>&1; then echo "[entrypoint] WARNING: graphify executable not found" >&2 echo "[entrypoint] continuing without building Graphify" >&2 else echo "[entrypoint] building Graphify graph" echo "[entrypoint] root=$GRAPHIFY_ROOT" echo "[entrypoint] graph=$GRAPHIFY_GRAPH" echo "[entrypoint] timeout=${GRAPHIFY_TIMEOUT}s" if ( cd "$GRAPHIFY_ROOT" && timeout "$GRAPHIFY_TIMEOUT" nice -n 10 graphify update . ); then if [ -f "$GRAPHIFY_GRAPH" ]; then echo "[entrypoint] Graphify graph ready: $GRAPHIFY_GRAPH" else echo "[entrypoint] WARNING: Graphify completed without creating:" >&2 echo "[entrypoint] $GRAPHIFY_GRAPH" >&2 fi else status=$? if [ "$status" = "124" ]; then echo "[entrypoint] WARNING: Graphify timed out after ${GRAPHIFY_TIMEOUT}s" >&2 else echo "[entrypoint] WARNING: Graphify failed with status $status" >&2 fi echo "[entrypoint] continuing without a Graphify graph" >&2 fi fi else echo "[entrypoint] Graphify graph present or build disabled; skipping build" fi # ----------------------------------------------------------------------------- # MCP readiness checks # ----------------------------------------------------------------------------- # # OpenCode launches every `local` MCP server from opencode.json as a stdio # process. Before starting OpenCode, probe each enabled server with a JSON-RPC # `initialize` handshake so a dead MCP is detected at boot instead of # mid-session. # # Env: # MCP_CHECK_ENABLED true|false enable the preflight (default: true) # MCP_CHECK_TIMEOUT seconds per-server timeout (default: 20) # MCP_CHECK_STRICT true|false exit 1 if any server is not ready (default: false) MCP_CHECK_ENABLED="${MCP_CHECK_ENABLED:-true}" MCP_CHECK_TIMEOUT="${MCP_CHECK_TIMEOUT:-20}" MCP_CHECK_STRICT="${MCP_CHECK_STRICT:-false}" MCP_CHECK_CONFIG="${OPENCODE_CONFIG:-$HOME/.config/opencode/opencode.json}" # Function body lives in scripts/boot/mcp-check.sh (moved verbatim); source it # conditionally so a missing engine degrades to a skip, matching the in-body # fallback semantics (warn + continue, never fail the boot chain). if [ -f /opt/engine/scripts/boot/mcp-check.sh ]; then . /opt/engine/scripts/boot/mcp-check.sh mcp_check_servers else echo "[entrypoint] WARNING: /opt/engine/scripts/boot/mcp-check.sh not found; skipping MCP readiness checks" >&2 fi # ----------------------------------------------------------------------------- # Engine boot self-check # ----------------------------------------------------------------------------- # # Optional preflight that validates the bundled workflow-state-engine before # OpenCode starts, using the same scripts a developer would run by hand. # # Env: # ENGINE_BOOT_CHECK true|false enable the self-check (default: false) # ENGINE_BOOT_STRICT true|false exit 1 if any check fails (default: false) # ENGINE_BOOT_TIMEOUT seconds per-check timeout (default: 30) ENGINE_BOOT_CHECK="${ENGINE_BOOT_CHECK:-false}" ENGINE_BOOT_STRICT="${ENGINE_BOOT_STRICT:-false}" ENGINE_BOOT_TIMEOUT="${ENGINE_BOOT_TIMEOUT:-30}" # Function body lives in scripts/boot/engine-check.sh (moved verbatim); source # it conditionally. The in-body /opt/engine guard is preserved; this elif only # covers the module file itself missing (same warning text as the in-body guard). if [ -f /opt/engine/scripts/boot/engine-check.sh ]; then . /opt/engine/scripts/boot/engine-check.sh engine_boot_checks elif [ "$ENGINE_BOOT_CHECK" = "true" ]; then echo "[entrypoint] WARNING: /opt/engine not found; skipping engine boot checks" >&2 fi # ----------------------------------------------------------------------------- # Start OpenCode # ----------------------------------------------------------------------------- # # v2 CLI subcommands/flags are not fully verified yet; probe each launch form # with --help before exec so an unsupported flag set degrades gracefully # instead of crash-looping the container. if opencode serve --help >/dev/null 2>&1; then echo "[entrypoint] starting: opencode serve --hostname 0.0.0.0 --port 4096" exec opencode serve --hostname 0.0.0.0 --port 4096 elif opencode web --help >/dev/null 2>&1; then echo "[entrypoint] starting: opencode web --hostname 0.0.0.0 --port 4096" exec opencode web --hostname 0.0.0.0 --port 4096 else echo "[entrypoint] WARN: neither serve nor web accepted; falling back to bare serve" >&2 exec opencode serve fi ================ File: workspace/agents/opencode/populate-preinstall.sh ================ #!/bin/sh # Populate the offline package store (pre-install/) used by the Docker build. # # pre-install/ is fully gitignored (except its .gitkeep files): its contents # live only on the local disk / inside the workspace volume and are NEVER # committed or pushed. The store is portable - copy the workspace to another # machine and the store comes with it. # # Idempotent: every artifact follows the same skip-if-exists pattern - when # its marker (tarball, wheel, binary, deb, clone dir, zip) is already in the # store, the fetch is skipped, so re-runs fetch only what is missing. Root is # required only for the apt section. Store sections: npm pip bin apt git dist. set -eu # Versions - must match the Dockerfile ARG defaults (env-overridable). OPENCODE_VERSION="${OPENCODE_VERSION:-2.2.5}" OPENCODE_ARCH="${OPENCODE_ARCH:-arm64}" # @opencode/cli-linux-${OPENCODE_ARCH} OPENCODE_PLATFORM_VERSION="${OPENCODE_PLATFORM_VERSION:-2.2.5}" CONTEXT7_VERSION="${CONTEXT7_VERSION:-4.4.4}" GRAPHIFY_VERSION="${GRAPHIFY_VERSION:-0.9.100}" LEAN_CTX_VERSION="${LEAN_CTX_VERSION:-3.12.0}" MCP_MEMORY_VERSION="${MCP_MEMORY_VERSION:-2.0.1}" MCP_EVERYTHING_VERSION="${MCP_EVERYTHING_VERSION:-2.1.0}" MCP_VERSION="${MCP_VERSION:-2.3.0}" MCP_SERVER_FETCH_VERSION="${MCP_SERVER_FETCH_VERSION:-2026.10.28}" # @firecrawl/opencode runtime dependency + CLI companion (offline store pins) ZOD_VERSION="${ZOD_VERSION:-4.1.8}" FIRECRAWL_CLI_VERSION="${FIRECRAWL_CLI_VERSION:-1.26.24}" STORE_DIR=$(cd "$(dirname "$0")" && pwd)/pre-install mkdir -p "$STORE_DIR/npm" "$STORE_DIR/pip" "$STORE_DIR/bin" "$STORE_DIR/apt" \ "$STORE_DIR/git" "$STORE_DIR/dist" ADDED=0 SKIPPED=0 mark_added() { ADDED=$((ADDED + 1)) echo " added: $1" } mark_skipped() { SKIPPED=$((SKIPPED + 1)) echo " skipped: $1" } # Generic skip-if-exists guard for one store artifact. # # fetch_if_missing MARKER CMD [ARG...] # # MARKER is a file (e.g. a downloaded zip) or a directory (e.g. a git clone); # it counts as present when it exists as a file or holds at least one entry # as a directory. Present -> return 1 so the caller can report a skip; # absent -> run CMD with the remaining arguments. Multi-step fetches pass a # shell function as CMD (POSIX sh has no arrays, so commands are dispatched # through "$@"). A failed fetch stays fatal: set -e is suspended in an # if-condition, so a nonzero CMD status is turned into an explicit exit. fetch_if_missing() { marker=$1 shift if [ -e "$marker" ] && { [ -f "$marker" ] || [ -n "$(ls -A "$marker" 2>/dev/null)" ]; }; then return 1 fi "$@" || exit $? } # -------------------------------------------------------------------------- # npm - one tarball per package. # -------------------------------------------------------------------------- # True when a tarball for the package (any version) is already in the store. # npm pack writes scoped packages as '@scope+name- .tgz'. # $glob is expanded unquoted on purpose (paths in this workspace have no spaces). npm_tarball_present() { pkg=${1%@*} case "$pkg" in @*/*) glob="$STORE_DIR/npm/${pkg%%/*}+${pkg#*/}-"*.tgz ;; *) glob="$STORE_DIR/npm/$pkg-"*.tgz ;; esac for f in $glob; do if [ -e "$f" ]; then return 0 fi done return 1 } npm_maybe_pack() { if npm_tarball_present "$1"; then mark_skipped "$1 (tarball already in store)" else npm pack "$1" --pack-destination "$STORE_DIR/npm" mark_added "$1" fi } echo "==> npm tarballs" npm_maybe_pack "@opencode/cli@${OPENCODE_VERSION}" npm_maybe_pack "@opencode/cli-linux-${OPENCODE_ARCH}@${OPENCODE_PLATFORM_VERSION}" npm_maybe_pack "@upstash/context7-mcp@${CONTEXT7_VERSION}" npm_maybe_pack "@modelcontextprotocol/server-memory@${MCP_MEMORY_VERSION}" npm_maybe_pack "@modelcontextprotocol/server-everything@${MCP_EVERYTHING_VERSION}" npm_maybe_pack "zod@${ZOD_VERSION}" npm_maybe_pack "firecrawl-cli@${FIRECRAWL_CLI_VERSION}" # NOTE: V1-API custom plugin tarballs (opencode-ai/plugin, model-fallback, # dcp, websearch-cited) are no longer packed - the plugins are removed from # the image until they are ported to the @opencode/plugin V2 API. # -------------------------------------------------------------------------- # pip - full wheel closure. # -------------------------------------------------------------------------- # True when at least one wheel is already in the store. pip_wheels_present() { for f in "$STORE_DIR"/pip/*.whl; do if [ -e "$f" ]; then return 0 fi done return 1 } # The container ships pipx but NOT python3-pip; prefer the graphifyy venv's # pip and fall back to python3 -m pip (e.g. on a dev host). fetch_pip_wheels() { if [ -x /opt/pipx/venvs/graphifyy/bin/pip ]; then /opt/pipx/venvs/graphifyy/bin/pip download --dest "$STORE_DIR/pip" \ "graphifyy[mcp]==${GRAPHIFY_VERSION}" "mcp==${MCP_VERSION}" "mcp-server-fetch==${MCP_SERVER_FETCH_VERSION}" else python3 -m pip download --dest "$STORE_DIR/pip" \ "graphifyy[mcp]==${GRAPHIFY_VERSION}" "mcp==${MCP_VERSION}" "mcp-server-fetch==${MCP_SERVER_FETCH_VERSION}" fi } echo "==> python wheels (full closure)" if pip_wheels_present; then mark_skipped "pip wheels (already in store)" else fetch_pip_wheels mark_added "pip wheels: graphifyy[mcp]==${GRAPHIFY_VERSION}, mcp==${MCP_VERSION}, mcp-server-fetch==${MCP_SERVER_FETCH_VERSION}" fi # -------------------------------------------------------------------------- # bin - release binaries. # -------------------------------------------------------------------------- # Fetch the lean-ctx release binary via its install script. fetch_lean_ctx() { HOME=$(mktemp -d) curl -fsSL \ "https://raw.githubusercontent.com/yvgude/lean-ctx/v${LEAN_CTX_VERSION}/install.sh" \ -o "$HOME/inst.sh" LEAN_CTX_NO_ONBOARD=1 sh "$HOME/inst.sh" --download cp "$HOME/.local/bin/lean-ctx" "$STORE_DIR/bin/lean-ctx" chmod 0755 "$STORE_DIR/bin/lean-ctx" rm -rf "$HOME" } echo "==> lean-ctx binary" if fetch_if_missing "$STORE_DIR/bin/lean-ctx" fetch_lean_ctx; then mark_added "lean-ctx ${LEAN_CTX_VERSION}" else mark_skipped "lean-ctx ${LEAN_CTX_VERSION} (binary already in store)" fi # -------------------------------------------------------------------------- # apt - .deb closure (root only). # -------------------------------------------------------------------------- # True when at least one .deb is already in the store. apt_debs_present() { for f in "$STORE_DIR"/apt/*.deb; do if [ -e "$f" ]; then return 0 fi done return 1 } fetch_apt_debs() { mkdir -p "$STORE_DIR/apt/partial" apt-get update apt-get install --reinstall --download-only -y --no-install-recommends \ -o Dir::Cache::archives="$STORE_DIR/apt" \ git curl jq ripgrep ca-certificates python3 pipx python3-venv coreutils procps } echo "==> apt debs (root only)" if [ "$(id -u)" -eq 0 ]; then if apt_debs_present; then mark_skipped "apt debs (already in store)" else fetch_apt_debs mark_added "apt debs" fi else echo " apt section skipped - re-run with: docker compose exec -u root opencode sh populate-preinstall.sh" fi # -------------------------------------------------------------------------- # git/ - shallow clones of git-sourced artifacts (reserved for future use). # # Pattern: the marker is the clone directory; the clone is skipped while it # holds at least one entry (an empty or partial clone refetches): # # fetch_if_missing "$STORE_DIR/git/ " \ # git clone --depth 1 "$STORE_DIR/git/ " # # Working example (uncomment and fill in when a git-sourced artifact is # needed; wrap with if/else + mark_added/mark_skipped to keep counts): # # echo "==> git clones" # # fetch_if_missing "$STORE_DIR/git/example-repo" \ # # git clone --depth 1 https://github.com/example/example-repo.git \ # # "$STORE_DIR/git/example-repo" # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- # dist/ - zips, tarballs, and other direct-URL downloads (reserved for # future use). # # Pattern: the marker is the downloaded file itself; the download is skipped # while the file exists: # # fetch_if_missing "$STORE_DIR/dist/ .zip" \ # curl -fsSL -o "$STORE_DIR/dist/ .zip" # # Working example (uncomment and fill in when a direct-download artifact is # needed): # # echo "==> direct downloads" # # fetch_if_missing "$STORE_DIR/dist/example-1.0.0.zip" \ # # curl -fsSL https://example.com/example-1.0.0.zip \ # # -o "$STORE_DIR/dist/example-1.0.0.zip" # -------------------------------------------------------------------------- echo "" echo "==> done: ${ADDED} added, ${SKIPPED} skipped - store at ${STORE_DIR}" echo " sections: npm pip bin apt git dist" ================ File: workspace/agents/opencode/README.md ================ # opencode Self-hosted OpenCode v2 stack in Docker: workflow-state-engine, lean-ctx, Graphify, MCP servers, version-pinned and offline-ready.     [](https://github.com/RizkiRachman/utility/actions/workflows/validate.yml) This folder packages OpenCode v2 (`@opencode/cli`, npm `anomalyco/opencode`) as a reproducible Docker stack: the workflow-state-engine (16 agents, 66 skills, contract-driven scoring), lean-ctx, Graphify, and Context7 / memory / fetch / everything / rag MCP servers, all version-pinned and pre-populated for offline builds. OpenCode v2 is early software, so this runs as a **beta experience**. The engine itself is documented in [engine/README.md](engine/README.md). ## Table of Contents 1. [Quick Start](#quick-start) 2. [Configuration](#configuration) 3. [Ports and Volumes](#ports-and-volumes) 4. [MCP Servers](#mcp-servers) 5. [Health Checks](#health-checks) 6. [Offline Pre-Install Store](#offline-pre-install-store) 7. [Components](#components) 8. [Project Structure](#project-structure) 9. [Architecture](#architecture) 10. [Enforcement and Quality Gates](#enforcement-and-quality-gates) 11. [Troubleshooting](#troubleshooting) 12. [Known Unknowns](#known-unknowns) 13. [Local State](#local-state) 14. [Contributing and License](#contributing-and-license) ## Quick Start From the repository root: ```bash cp workspace/agents/opencode/.env.template .env # fill in your keys docker compose -f docker-compose.example.yaml up -d --build open http://127.0.0.1:4096 # OpenCode pairing screen, enter your key once ``` Standalone build and run, without the repository compose file: ```bash docker build --build-context preinstall=./pre-install -t opencode:local . docker run --rm -it -p 127.0.0.1:4096:4096 \ -e OPENCODE_API_KEY=" " \ -v "$PWD/config/opencode.json":/home/node/.config/opencode/opencode.json:ro \ opencode:local open http://127.0.0.1:4096 # OpenCode's own pairing screen, enter your key once ``` Both the compose stack and the standalone run above publish OpenCode on `127.0.0.1:4096` (loopback only). The first visit shows OpenCode's own pairing prompt; enter the key there. The entrypoint boot chain: write `OPENCODE_API_KEY` to `auth.json` and unset it, optionally build the Graphify index, run the MCP readiness preflight, optionally run engine boot checks, then probe launch forms in order: `opencode serve --hostname 0.0.0.0 --port 4096` → `opencode web --hostname 0.0.0.0 --port 4096` → bare `opencode serve`. ## Configuration Compose file: [`docker-compose.example.yaml`](../../../docker-compose.example.yaml) at the repository root is the canonical entry point; the sibling [`docker-compose.example.yaml`](docker-compose.example.yaml) in this folder runs the same stack standalone. Both publish OpenCode serve's `4096` on `127.0.0.1:4096` (loopback only). ### Runtime environment | Variable | Required | Default | Description | |---|---|---|---| | `OPENCODE_API_KEY` | Yes | `REPLACE_ME` | OpenCode auth; written to `auth.json`, then unset from the env | | `OPENCODE_SERVER_PASSWORD` | No | (empty) | Auth key for OpenCode's own HTTP basic auth / pairing screen | | `SUMOPOD_API_KEY` | Yes | `REPLACE_ME` | Model-provider API key | | `SUMOPOD_BASE_URL` | Yes | `REPLACE_ME` | Model-provider endpoint | | `SUMOPOD_BASE_MODEL` | Yes | `REPLACE_ME` | Executor model id | | `SUMOPOD_BASE_MODEL_NAME` | Yes | `REPLACE_ME` | Executor model display name | | `CONTEXT7_API_KEY` | Yes | `REPLACE_ME` | Context7 docs MCP key | | `FIRECRAWL_API_KEY` | No | (unset) | Firecrawl key; developer search works keyless, alexandria + firecrawl-cli skills need it | | `RAG_EMBEDDINGS_URL` | No | (empty) | OpenAI-compatible `/v1/embeddings` endpoint for the rag server; all three `RAG_EMBEDDINGS_*` set switches it to remote embeddings | | `RAG_EMBEDDINGS_MODEL` | No | (empty) | Embeddings model id at the endpoint above | | `RAG_EMBEDDINGS_KEY` | No | (empty) | Bearer key for the embeddings endpoint | | `OPENCODE_AUTH_REQUIRED` | No | `true` | Fail boot when `OPENCODE_API_KEY` is missing | | `OPENCODE_CONFIG` | No | `/home/node/.config/opencode/opencode.json` | Config path inside the container | | `GRAPHIFY_ROOT` | No | `/home/node/workspace` | Graphify index root | | `GRAPHIFY_BUILD` | No | `if-missing` | `if-missing` / `always` / `never` | | `GRAPHIFY_TIMEOUT` | No | `900` | Graph build timeout (seconds) | | `MCP_CHECK_ENABLED` | No | `true` | Boot-time MCP readiness preflight | | `MCP_CHECK_TIMEOUT` | No | `20` | Per-server probe timeout (seconds) | | `MCP_CHECK_STRICT` | No | `false` | Exit 1 when a server is not ready | | `ENGINE_BOOT_CHECK` | No | `false` | Validate the bundled engine before launch | | `ENGINE_BOOT_STRICT` | No | `false` | Exit 1 when a boot check fails | | `ENGINE_BOOT_TIMEOUT` | No | `30` | Per-check timeout (seconds) | ### Build args | Arg | Default | Description | |---|---|---| | `OPENCODE_VERSION` | `2.2.5` | OpenCode CLI pin | | `OPENCODE_ARCH` | `arm64` | Platform package (`@opencode/cli-linux-arm64`); x64 builds override | | `GRAPHIFY_VERSION` | `0.9.100` | `graphifyy[mcp]` pin (PyPI) | | `LEAN_CTX_VERSION` | `3.12.0` | lean-ctx pin | | `CONTEXT7_VERSION` | `4.4.4` | `@upstash/context7-mcp` pin | | `MCP_VERSION` | `2.3.0` | MCP Python SDK pin | | `MCP_SERVER_FETCH_VERSION` | `2026.10.28` | `mcp-server-fetch` pin | | `ZOD_VERSION` | `4.1.8` | zod pin (runtime dep of `@firecrawl/opencode`, resolved once) | | `FIRECRAWL_CLI_VERSION` | `1.26.24` | `firecrawl-cli` pin (binary `firecrawl`) | Config files: `config/opencode.json` (V2-native schema: `agents`, `permissions` (incl. a `firecrawl_alexandria` ask entry), `plugins` (vendored `/opt/engine/plugins/opencode-firecrawl`), `mcp.servers` + `disabled`, `providers.package`, `instructions` (loads `/opt/engine/agent.md` plus `opencode/config/rag-usage.md`, which tells agents to search the rag index before writing code), `update`, `compaction.keep.tokens`; mounted read-only) and `config/opencode-model-fallback.json` (dormant, kept for a future `@opencode/plugin` port). ## Ports and Volumes | Host | Container | Notes | |---|---|---| | `127.0.0.1:4096` | `4096` | OpenCode web UI (serve); loopback only | | `./workspace` | `/home/node/workspace` | Working tree mounted into the container (repo compose) | | `config/opencode.json` | `/home/node/.config/opencode/opencode.json` | Read-only bind mount; restart to apply edits | | `config/opencode-model-fallback.json` | `/home/node/.config/opencode/opencode-model-fallback.json` | Read-only, dormant | | `opencode-data` (named volume) | `/home/node/.local/share/opencode` | Auth, session store, memory MCP file | | `pre-install/` | build context `preinstall` | Offline package store, consumed at build time | ## MCP Servers Defined in `config/opencode.json`: | Server | Command | Purpose | |---|---|---| | `lean-ctx` | `lean-ctx` | Compressed reads/search/shell, code intelligence, knowledge and session persistence | | `graphify` | `sh -c "… graphify-mcp …"` | Knowledge graph MCP; configured but `disabled: true` by default, flip the flag and restart to enable | | `context7` | `context7-mcp` | Up-to-date library docs lookups; needs `CONTEXT7_API_KEY` | | `memory` | `mcp-server-memory` | Persistent memory graph, writes to `MEMORY_FILE_PATH` in the `opencode-data` volume | | `fetch` | `mcp-server-fetch` | Keyless single-page HTTP GET with readability extraction (deps baked at build) | | `everything` | `mcp-server-everything` | MCP diagnostics and test fixtures | | `rag` | `node /opt/rag/server.js` | Workspace retrieval over SQLite FTS5 + vectors: `rag_search`, `rag_index`, `rag_status`, `rag_get`; java-aware method-boundary chunking with `package > class > method` headings; offline hashed-vector embeddings, optional remote endpoint via `RAG_EMBEDDINGS_*`; agents get usage rules from the `opencode/config/rag-usage.md` instructions entry | | `@firecrawl/opencode` (plugin) | vendored at `engine/plugins/opencode-firecrawl` | Keyless `firecrawl_developer_search`; `firecrawl_alexandria` is ask-gated; keyed scrape/crawl via `firecrawl-cli` | ## Health Checks Run from this folder (`workspace/agents/opencode`); every script exits 0 on a healthy tree: ```bash bash engine/scripts/health-check.sh # aggregates the full suite (6 dimensions) bash engine/scripts/check-cross-references.sh # link/ID integrity across engine docs bash engine/scripts/validate-toolkit.sh # symlinks, contract integrity, agent consistency bash engine/scripts/check-conventions.sh # architecture conventions (non-blocking) bash engine/scripts/check-doc-style.sh # doc style gate: dashes, vocabulary, placeholders bash engine/scripts/check-plugins.sh # V2 plugin path rule (no file:// to file) bash engine/scripts/check-mcp.sh # MCP server binaries present bash engine/scripts/boot/engine-check.sh # boot-time engine validation bash engine/scripts/boot/mcp-check.sh # boot-time MCP readiness probe ``` Details: [engine/doc/health-check.md](engine/doc/health-check.md). ## Offline Pre-Install Store `pre-install/` holds apt `.deb` files, pip wheels, npm tarballs, git sources, and prebuilt binaries keyed to the pinned versions and `OPENCODE_ARCH` (default `arm64`). The compose `opencode-preinstall` service (profile `tools`) is a throwaway `node:22-bookworm-slim` container that fills it; repeat runs skip everything already present. The image build consumes the store via the `preinstall` build context; without it, installs fall back to the network and fail in offline environments. The store is gitignored except `.gitkeep` placeholders. ## Components | Component | Pin | Source | |---|---|---| | OpenCode v2 CLI | `@opencode/cli@2.2.5` + platform pkg `@opencode/cli-linux-${OPENCODE_ARCH}@2.2.5` | npm (`anomalyco/opencode`) | | Context7 MCP | `@upstash/context7-mcp@4.4.4` | npm | | Memory MCP | `@modelcontextprotocol/server-memory@2.0.1` | npm | | Everything MCP | `@modelcontextprotocol/server-everything@2.1.0` | npm | | Graphify | `graphifyy[mcp]==0.9.100` | PyPI | | MCP Python SDK | `mcp==2.3.0` | PyPI | | Fetch MCP | `mcp-server-fetch==2026.10.28` | PyPI | | lean-ctx | `v3.12.0` | [GitHub release](https://github.com/yvgude/lean-ctx) | | @firecrawl/opencode | `1.4.0` (ISC, first-party) | vendored at `engine/plugins/opencode-firecrawl` ([upstream](https://github.com/firecrawl/opencode-firecrawl)) | | firecrawl-cli | `1.26.24` (ISC, binary `firecrawl`) | npm | **Custom plugins removed** (`@razroo/opencode-model-fallback@0.3.2`, `@tarquinen/opencode-dcp@3.2.0`, `opencode-websearch-cited@1.2.0`): they target the V1 plugin API and V2 additionally rejects their old `file:// ` entries (`configured plugin path must be a directory`), so they never loaded. They are dropped from the image and `config/opencode.json` until ported to the `@opencode/plugin` V2 API; no fallback executor→reviewer resilience is active until then. Contrast: the first-party V2-native `@firecrawl/opencode` plugin is adopted offline-vendored at `engine/plugins/opencode-firecrawl` (developer search works keyless out of the box); the community V1-era plugins above remain removed. ## Project Structure ```text opencode/ ├── Dockerfile # Image build; consumes the preinstall context ├── entrypoint.sh # Boot chain: auth → graphify → MCP preflight → serve ├── docker-compose.example.yaml # opencode + preinstall + scheduler services ├── rag/ # RAG MCP server (zero npm dependencies) │ ├── server.js # CLI + MCP stdio; SQLite FTS5 + vectors │ ├── README.md │ ├── test.sh # 10-case suite (CLI, Java chunking, MCP, remote embeddings) │ └── test/ # corpus fixtures, embeddings stub, MCP checks ├── populate-preinstall.sh # Offline-store bootstrap (run inside preinstall service) ├── .env.template # Copy to repository-root .env ├── config/ # opencode.json (V2-native), rag-usage.md agent guidance, dormant fallback config ├── certs/ # TLS certs placeholder (currently empty) ├── pre-install/ # Offline package store (gitignored) ├── session/ # Live orchestration contract + state log (gitignored) ├── sandbox/ # Session playground, one folder per session (contents gitignored) └── engine/ # Workflow-state-engine → copied to /opt/engine ├── agent.md # Orchestrator instructions (instructions[0]) ├── agents/ # 16 agent instruction files ├── skills/ # 66 skill directories ├── commands/ # 1 slash command (impeccable) ├── rules/ # rules.json + rules.schema.json ├── contract/ # Contract templates + JSON Schema ├── scripts/ # Automation & validation scripts (incl. boot/) ├── usage/ # 4 tool guides (lean-ctx, graphify, context7, opencode) ├── doc/ # Workflow + health-check docs ├── templates/ # judge-prompt.md └── opencode.json.template # Config template (V2-native shape) ``` CI lives at the repository root: [.github/workflows/validate.yml](../../../.github/workflows/validate.yml) runs JSON validity, a `bash -n` sweep, cross-reference and doc style gates, and contract scoring on every push. ## Architecture  *Generated by [engine/scripts/generate_diagrams.py](engine/scripts/generate_diagrams.py); regenerate with `python3 engine/scripts/generate_diagrams.py` from the repository root.* ```text Host browser → 127.0.0.1:4096 (loopback, published) → opencode serve :4096 entrypoint.sh boot chain: 1. auth: OPENCODE_API_KEY to auth.json, then unset 2. Graphify build: if-missing / always / never, timeout 900 3. MCP preflight: JSON-RPC initialize per enabled local server 4. engine boot checks: validate-contract --score, check-plugins 5. launch probe: serve, then web, then bare serve opencode serve attaches to: - MCP servers: lean-ctx, context7, memory, fetch, everything, rag (graphify disabled by default) - Firecrawl plugin: keyless developer search, alexandria ask-gated - workflow-state-engine: 16 agents, 66 skills, session/, sandbox/ ``` ## Enforcement and Quality Gates | Gate | Checks | Wired into | |---|---|---| | `engine/scripts/check-cross-references.sh` | 8-check link/ID integrity + context-standards + access-policy verification | `health-check.sh` lens-4, `validate-toolkit.sh`, pre-commit hook, CI | | `engine/scripts/check-doc-style.sh` | Doc style gate: em/en dashes, AI vocabulary, placeholders in shipped docs | `health-check.sh --check docs`, `validate-toolkit.sh`, pre-commit hook, CI | | `engine/scripts/check-plugins.sh` | Rejects `file://`-to-file plugin paths (V2 rule) | `setup.sh`, `validate-toolkit.sh`, engine boot checks | | `engine/scripts/validate-contract.sh --score` | 3-tier contract scoring (PASS ≥ 70) | CI, engine boot checks | | `engine/scripts/check-conventions.sh` | Rule 1 FAILs stale contract paths; INFOs the V2 chain | `validate-toolkit.sh`, CI (non-blocking) | | `engine/rules/rules.schema.json` | `store_in` target schema + required top-level keys for rules | `validate-toolkit.sh` | | `engine/.githooks/pre-commit` | `bash -n` sweep + cross-references + doc style | `git config core.hooksPath engine/.githooks` | | `.github/workflows/validate.yml` (repository root) | JSON validity, `bash -n`, cross-refs, doc style, contract score, conventions | Runs on every push and pull request | | humanizer gate (`/skill humanizer`) | Strips AI writing patterns (em dashes, rule-of-three, promotional tone) from shipped user-facing docs; internal state files (`session/state.md`, contract JSON, code comments) exempt | `agent.md` ship flow, `engine/skills/release-plan`, `engine/skills/shipping-and-launch` | | context gate (`/skill context-standards`) | Loads target-project pattern standards (`context/*.md`) before edits and verifies them in review | `agent.md` Execute step, `developer.md`, `quality-analyst.md`, cross-reference check 7 | | `engine/scripts/loop-validate.sh` | In-loop executor done-gate: JSON parse, `bash -n` sweep, cross-references; mirrors CI locally | developer / fixer / system-analyst rule 7, orchestrator scoring table (Tier 1) | | Agent permission tiers (`config/opencode.json`) | Read-only agents deny edit, shell, webfetch, question; global edit-deny on `**/opencode.json` and `**/contract.json` | OpenCode permission checks on every tool call | | Sandbox policy (`rules.json → enforcement.sandbox`) | Scratch work confined to `sandbox/ /`; own folder only, root never written, contents gitignored, promote before COMPLETE | `engine/agent.md` section 6, writer agents' Permissions blocks, `sandbox/README.md` | ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Build fails on x64 with missing platform package | The `pre-install/` store matches `OPENCODE_ARCH` (default `arm64`) | Set `OPENCODE_ARCH=x64`, re-run the preinstall profile, rebuild | | Forgot the key | OpenCode's pairing screen expects `OPENCODE_SERVER_PASSWORD` from the repository root `.env` | Run `grep OPENCODE_SERVER_PASSWORD .env` from the repository root; change it there and restart the `opencode` service | | Web UI unreachable, port already allocated | Host port 4096 is taken by another process | Change the host side of the `ports:` mapping in the compose file | | Config edits have no effect | `config/opencode.json` is mounted read-only | Restart the container after editing; the mount itself never changes | | `fetch` MCP fails to extract pages | Image predates the baked readabilipy dependencies; older builds installed them per request and needed network | Rebuild from this Dockerfile; the readability node modules are baked in | | Memory disappears between sessions | Memory MCP persists to `MEMORY_FILE_PATH` inside the `opencode-data` volume | Removing the volume removes memory; inspect `docker volume` before wiping | | `rag_search` returns nothing | The index is empty or the server reads a different database than expected | Run `rag_index` (or `node /opt/rag/server.js index`), then check `rag_status`: it prints the db path, the indexed root, and a stale file count; make sure `RAG_DB` matches the path you expect | | rag embedding mode looks wrong | An index keeps the embedding mode it was built with; `rag_search` uses that mode, and switching `RAG_EMBEDDINGS_*` only takes hold on the next `rag_index` | Re-run `rag_index` after changing the env; `rag_status` prints `embedding_mode` and `dims` | ## Known Unknowns - **V2 config schema** (resolved as of 2026-11-01): `config/opencode.json` migrated to the V2-native shape; deprecated keys (`agent`, `permission`, `mcp. `, `autoupdate`, `compaction.reserved`, `bash`) must not be reintroduced. - **Plugin API**: the three custom plugins were removed (see [Components](#components)); port them to `@opencode/plugin@2.x` before re-adding. - **`opencode web` flags**: v2 CLI subcommands are not fully verified. `entrypoint.sh` probes at boot: `opencode serve --hostname … --port …` → fallback `opencode web --hostname … --port …` → fallback bare `opencode serve`. ## Local State Never committed or pushed: - `pre-install/`: offline package store (gitignored except `.gitkeep` placeholders). - `session/`: **project-local runtime state**. Compose mounts the workspace at `/home/node/workspace`, so this `session/` *is* the container's global session directory: `contract.json` (seeded from `engine/contract/contract.template.json` via `snapshot-contract.sh`), `state.md`, `state.example.md` (tracked), `archive/`. Gitignore policy: `session/*` ignored, `state.example.md` negated. - `sandbox/`: **session playground**. One folder per live session (`sandbox/ /`) for probes, clones, and rendered tests. Tracked: `README.md`, `.gitignore`, `.gitkeep`; everything else is gitignored on the `certs/` pattern. Rules: [sandbox/README.md](../../../sandbox/README.md). The engine reference ([engine/README.md](engine/README.md)) covers internals. ## Contributing and License The stack is internal and not open for external contribution. Changes are made in place; the CI workflow at the repository root and a PR template ([.github/PULL_REQUEST_TEMPLATE.md](../../../.github/PULL_REQUEST_TEMPLATE.md)) apply the usual gates automatically. For local commits, enable the pre-commit hook with `git config core.hooksPath engine/.githooks`. No license file ships with this repository. ================================================================ End of Codebase ================================================================