CCE

Caging the beasts

Contents

I've been using some cheap code generation tools to help me make arcology2go. Tokenizing and transmitting parts of my personal knowledge to these fucking ghouls is sickening, and doing so without constraints horrifying. Folks are going out there giving a remote server the ability to execute code on their servers. Mine get a tiny VM and only have write access to some files that are auto-snapshot on the host filesystem every 15 minutes.

What's MicroVM?

References

A Nix Flake to build NixOS and run it on one of several Type-2 Hypervisors on NixOS/Linux or macOS. The project is intended to provide a more isolated alternative to nixos-container. You can either build and run MicroVMs like Nix packages, or alternatively install them as systemd services declaratively in your host's Nix Flake or imperatively with the provided microvm command.

nix:tangle ~/nix/snippets/microvm-input.nix
microvm = {
  url = "github:microvm-nix/microvm.nix";
  inputs.nixpkgs.follows = "nixpkgs";
};

a microvm for langlemangling

https://github.com/microvm-nix/microvm.nix/blob/789c90b164b55b4379e7a94af8b9c01489024c18/doc/src/shares.md?plain=1#L39

nix:tangle ~/nix/nixos/langlemangle-microvm.nix
{ inputs
, pkgs
, lib
, config
, ... }:
let
  microvm = inputs.microvm;
  nixpkgs-unstable = inputs.nixpkgs-unstable;
  pkgs2 = import nixpkgs-unstable {
    system = "x86_64-linux";
    config.allowUnfree = true;
    # libolm for Hermes Matrix E2EE (deprecated upstream, but required)
    config.permittedInsecurePackages = [ "olm-3.2.16" ];
    overlays = [ (import ../overlay.nix { inherit inputs; }) ]; # for pi-web, custom opencode...
  };
in {
  imports = [
    microvm.nixosModules.host
  ];

  microvm.host.enable = true;
  microvm.vms.clown = {
    autostart = false;
    # The VM config is a *separate* eval-config call; modules inside it
    # (microvm-hermes.nix, rrix.nix, ...) take `inputs` as a module
    # argument, so it must be provided as specialArgs here.
    specialArgs = { inherit inputs; };
    config = import ./microvm-langlemangler.nix {
      # inherit pkgs;
      pkgs = pkgs2;
      nixpkgs = nixpkgs-unstable;
      inherit inputs;
    };
  };

  networking.nat = {
    enable = true;
    internalInterfaces = [ "virbr0" ];
    externalInterface = "eno3";
  };
}

Here's the VM configuration:

nix:tangle ~/nix/nixos/microvm-langlemangler.nix
{ inputs, nixpkgs, pkgs, ... }: 

let
  microvm = inputs.microvm;

  writeable = "/nix/.rw-store";
in {
  imports = [
    ./rrix.nix
    microvm.nixosModules.microvm
    inputs.home-manager.nixosModules.home-manager
    ./microvm-hermes.nix
    # ./microvm-opencode.nix
  ];
  # nixpkgs.overlays = [ overlay ];

  home-manager.useGlobalPkgs = true;
  home-manager.backupFileExtension = "hm-bak";
  home-manager.extraSpecialArgs = { inherit inputs; };

  time.timeZone = "America/Los_Angeles";

  nix.nixPath = [
    "nixpkgs=${nixpkgs}"
  ];

  microvm = {
    # Enable writable nix store overlay so nix-daemon works.
    # This is required for home-manager activation.
    # Uses tmpfs by default (ephemeral), which is fine since we
    # don't build anything in the VM.
    writableStoreOverlay = writeable;

    volumes = [
      {
        mountPoint = "/var";
        image = "var.img";
        size = 8192; # MB
      }

      {
        image = "nix-store-overlay.img";
        mountPoint = writeable;
        size = 32876;
      }
    ];

    shares = [
      {
        # use proto = "virtiofs" for MicroVMs that are started by systemd
        proto = "virtiofs";
        tag = "ro-store";
        source = "/nix/store";
        mountPoint = "/nix/.ro-store";
      }
      {
        proto = "virtiofs";
        tag = "ssh-keys";
        source = "/home/rrix/claude-microvm/ssh-host-keys";
        mountPoint = "/etc/ssh/host-keys";
      }
      {
        proto = "virtiofs";
        tag = "claude-credentials";
        source = "/home/rrix/claude-microvm";
        mountPoint = "/home/rrix/claude-microvm";
      }
      {
        proto = "virtiofs";
        tag = "ollama-credentials";
        source = "/home/rrix/claude-microvm/ollama";
        mountPoint = "/home/rrix/.ollama";
      }
      {
        # Persistent ~/.config so HM-managed configs (opencode.json, etc.)
        # survive the tmpfs root. The opencode-web systemd user service reads
        # from $HOME/.config/opencode/opencode.json which the HM module
        # generates here. Create the source dir on the host before booting.
        proto = "virtiofs";
        tag = "xdh-config";
        source = "/home/rrix/claude-microvm/config";
        mountPoint = "/home/rrix/.config";
      }
      {
        proto = "virtiofs";
        tag = "arcology";
        source = "/home/rrix/Code/arcology2go";
        mountPoint = "/home/rrix/Code/arcology2go";
      }
      {
        proto = "virtiofs";
        tag = "workspace";
        source = "/home/rrix/Code/claude-projects";
        mountPoint = "/home/rrix/Code/claude-projects";
      }
      {
        proto = "virtiofs";
        tag = "org";
        source = "/home/rrix/org";
        mountPoint = "/home/rrix/org";
      }
      {
        proto = "virtiofs";
        tag = "nix";
        source = "/home/rrix/nix";
        mountPoint = "/home/rrix/nix";
      }
    ];

    interfaces = [{
      type = "user";
      id = "qemu";
      mac = "02:00:00:01:01:01";
    }];
    forwardPorts = [
      {
        from = "host";
        host.port = 10022;
        guest.port = 22;
      }
      {
        from = "host";
        host.port = 8080;
        guest.port = 8080;
      }
      {
        from = "host";
        host.port = 11434;
        guest.port = 11434;
      }
      # {
      #   from = "host";
      #   host.port = 8504;
      #   guest.port = 8504;
      # }
    ];

    hypervisor = "qemu";
    vcpu = 8;
    mem = 32768;
    socket = "control.socket";
  };

  services.openssh.enable = true;
  services.openssh.hostKeys = [{
    path = "/etc/ssh/host-keys/ssh_host_ed25519_key";
    type = "ed25519";
  }];

  networking.nameservers = [
    "100.100.100.100"
    "1.1.1.1"
  ];
  networking.firewall.enable = false;

  # Fix for microvm shutdown hang (issue #170):
  # Without this, systemd tries to unmount /nix/store during shutdown,
  # but umount lives in /nix/store, causing a deadlock.
  systemd.mounts = [
    {
      what = "store";
      where = "/nix/store";
      overrideStrategy = "asDropin";
      unitConfig.DefaultDependencies = false;
    }
  ];

  environment.systemPackages = with pkgs; [
    bashInteractive
    htop
    vim-full
    
    nodejs
    poppler-utils
    gnumake
  ];

  programs.nix-ld.enable = true; 

  home-manager.users.rrix = {
    home.stateVersion = "25.05";
    imports = [
      ../hm/emacs.nix
      ../hm/direnv.nix
      ../hm/git.nix
      ../hm/profile.nix
      ../hm/prompt.nix
      ../hm/pi-web.nix
    ];
    programs.home-manager.enable = true;

    home.sessionVariables = {
      NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1;
      NIXPKGS_ALLOW_UNFREE=1;
      CLAUDE_CONFIG_DIR="/home/rrix/claude-microvm";
      XDG_DATA_HOME="/home/rrix/claude-microvm/share";
      XDG_CACHE_HOME="/home/rrix/claude-microvm/cache";
      OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
      ANDROID_HOME = "${inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-sdk}/libexec/android-sdk";
      GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-sdk}/libexec/android-sdk/build-tools/35.0.0/aapt2";
    };

    programs.opencode = {
      enable = true;
      package = pkgs.opencode;
      extraPackages = [ pkgs.direnv ];
      settings = {
        plugin = [
          "@simonwjackson/opencode-direnv"
        ];
        mcp.arcology-notes = {
          type = "local";
          command = [
            "uv" "run" "/home/rrix/.local/share/arcology-notes-mcp.py"
          ];
          environment = {
            ARCOLOGY_RISK_ENDPOINT = "http://localhost:11434";
            ARCOLOGY_RISK_MODEL = "tev1:0.8b";
          };
          enabled = true;
        };
        mcp.ollama-search = {
          type = "local";
          command = [
            "uv" "run" "/home/rrix/.local/share/ollama-web-search-mcp.py"
          ];
          environment = {
            OLLAMA_API_KEY = "{env:OLLAMA_API_KEY}";
          };
          enabled = true;
        };
        server = {
          port = 8080;
          hostname = "0.0.0.0";
          cors = [ "http://last-bank:8080" ];
        };
        provider.ollama = {
          name = "Ollama";
          npm = "@ai-sdk/openai-compatible";
          options.baseURL = "http://127.0.0.1:11434/v1";
          models = {
            "glm-5.3:cloud" = { _launch = true; limit = { context = 999000; output = 131072; }; name = "glm-5.3:cloud"; };
            "glm-5.3-flash:cloud" = { _launch = true; limit = { context = 999000; output = 131072; }; name = "glm-5.3-flash:cloud"; };
            "glm-5.2:cloud" = { _launch = true; limit = { context = 999000; output = 131072; }; name = "glm-5.2:cloud"; };
            "deepseek-v4-pro:cloud" = { _launch = true; limit = { context = 1048576; output = 1048576; }; name = "deepseek-v4-pro:cloud"; };
            "deepseek-v4-flash:cloud" = { _launch = true; limit = { context = 1048576; output = 1048576; }; name = "deepseek-v4-flash:cloud"; };
            "kimi-k2.7-code:cloud" = { _launch = true; limit = { context = 262144; output = 262144; }; name = "kimi-k2.7-code:cloud"; };
            "kimi-k3:cloud" = { _launch = true; limit = { context = 262144; output = 262144; }; name = "kimi-k3:cloud"; };
            "qwen3-coder-next:cloud" = { _launch = true; limit = { context = 262144; output = 32768; }; name = "qwen3-coder-next:cloud"; };
            "qwen3.5:cloud" = { _launch = true; limit = { context = 262144; output = 32768; }; name = "qwen3.5:cloud"; };
            "gemma4:31b-cloud" = { _launch = true; limit = { context = 202752; output = 131072; }; name = "gemma4:31b-cloud"; };
          };
        };
      };
      web = {
        enable = true;
        extraArgs = [
          "--hostname" "0.0.0.0"
          "--port" "8080"
          "--cors" "http://virtuous-cassette:8080"
        ];
        environmentFile = pkgs.writeTextFile {
          name = "opencode-env";
          text = ''
              XDG_DATA_HOME=/home/rrix/claude-microvm/share
              NIXPKGS_ALLOW_UNFREE=1
              NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1
            '';
        };
      };
    };

    home.packages = [
      pkgs.tmux
      inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.default
      inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-env

      pkgs.ollama
      pkgs.claude-code
      pkgs.pi-coding-agent
      pkgs.pi-web

      pkgs.uv
    ];

    # Ollama web search MCP server.
    # Upstream: https://github.com/ollama/ollama-python/blob/main/examples/web-search-mcp.py
    # Vendored and patched locally — see the "ollama web-search MCP server"
    # section below for the source and the reason it can't be fetched raw.
    # The arcology2go opencode.json references this path in its mcp.ollama-search block.
    home.file.".local/share/ollama-web-search-mcp.py".source = ../files/ollama-web-search-mcp.py;
    home.file.".local/share/arcology-notes-mcp.py".source = ../files/arcology-notes-mcp.py;
    services.ollama.enable = true;
    services.ollama.package = pkgs.ollama;
    services.ollama.host = "0.0.0.0";

    services.pi-web = {
      enable = false;
      dataDir = "/home/rrix/claude-microvm/pi-web";
      piPackage = pkgs.pi-coding-agent;
      host = "0.0.0.0";
      ollama = {
        enable = true;
        agentDir = "/home/rrix/claude-microvm/pi-agent";
      };
    };

  };
}

Hermes Agent

Hermes Agent runs as a home-manager user service under rrix (see the services.hermes-agent block in the VM config above). The flake input provides the module. Why HM and not the NixOS module: the agent and its tools read the virtiofs shares (~/org =, ~/Code/arcology2go, ~/nix) which are owned by rrix, every other service in this VM (opencode-web, pi-web, ollama) is already a user service, and HERMES_HOME needs to live on the persistent claude-microvm share because the VM root is tmpfs. The NixOS module would run hermes as its own system user with no access to any of that. users.users.rrix.linger is set so the gateway survives logout.

Managed-mode caveats apply: hermes setup / hermes config set are blocked; the config comes from settings in the Nix block, secrets come from the env file. Model routing: main conversations on glm-5.3:cloud, cheap auxiliary work (vision, compression, titles, MCP dispatch) on glm-5.3-flash:cloud — the same ollama cloud configuration the opencode.json provider block uses, just through Hermes' named-custom-provider surface at http://127.0.0.1:11434/v1.

Memory uses the holographic provider: local SQLite with FTS5, HRR compositional queries, trust scoring — no external dependencies, and the DB lands on the persistent share next to the rest of the Hermes state.

Hermes Agent flake input

Hermes Agent ships its own flake with NixOS and Home Manager modules built via uv2nix. Only this VM consumes it so far, so it gets its own input snippet rather than piggybacking on the shared microvm input. Its input nixpkgs follows nixpkgs-unstable rather than the 26.05 pin: hermes-state needs SQLite 3.51.3+ (WAL-reset corruption fix; 26.05's 3.51.2 lacks it so every DB silently falls back to =journal_mode=DELETE=), and unstable ships 3.53.3. This closes the NEXT upgrade task.

nix:tangle ~/nix/snippets/hermes-agent-input.nix
hermes-agent = {
  url = "github:NousResearch/hermes-agent";
  # nixpkgs-unstable, not the 26.05 pin: hermes-state depends on SQLite
  # 3.51.3+ (or backports 3.50.7/3.44.6) for the WAL-reset corruption fix;
  # 26.05's python3.14 links 3.51.2 which lacks it, so every DB falls back
  # to journal_mode=DELETE. Unstable ships sqlite 3.53.3.
  inputs.nixpkgs.follows = "nixpkgs-unstable";
};

NixOS/MicroVM configuration for Hermes

nix:tangle ~/nix/nixos/microvm-hermes.nix
{ inputs, pkgs, ... }:

{
  users.users.rrix.linger = true;
  home-manager.users.rrix.imports = [
      inputs.hermes-agent.homeManagerModules.default
  ];

  home-manager.users.rrix = {
    services.hermes-agent = {
      enable = true;
      gateway.enable = true;
      backend.mode = "dashboard";
      backend.host = "0.0.0.0";
      backend.port = 9119; 
      hermesHome = "/home/rrix/claude-microvm/hermes";
      environmentFiles = [ "/home/rrix/claude-microvm/hermes-env" ];
      # Matrix + E2EE: mautrix with encryption extras, built into the
      # sealed venv at build time. libolm comes from nixpkgs.
      extraDependencyGroups = [ "matrix" ];
      extraPackages = [
        pkgs.olm
        # arcology2go notes CLI — the arcology-notes skill and the
        # opencode.json permissions both rely on this being on PATH.
        inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.default
      ];

      settings = {
        # Same Ollama cloud configuration as the opencode.json provider
        # block above: the VM's local ollama proxies the :cloud models.
        providers.ollama = {
          api = "http://127.0.0.1:11434/v1";
          key_env = "OLLAMA_API_KEY";
          transport = "chat_completions";
        };
        model = {
          provider = "custom:ollama";
          default = "glm-5.3-flash:cloud";
        };
        auxiliary = {
          vision = { model = "glm-5.3-flash:cloud"; };
          compression = { model = "glm-5.3-flash:cloud"; };
          title_generation = { model = "glm-5.3-flash:cloud"; };
          mcp = { model = "glm-5.3-flash:cloud"; };
        };
        memory = {
          memory_enabled = true;
          user_profile_enabled = true;
          provider = "holographic";
        };
        plugins.hermes-memory-store = {
          db_path = "/home/rrix/claude-microvm/hermes/memory_store.db";
          auto_extract = true;
        };
        # Risk-gate env for the arcology notes tools; the skill also
        # declares these in required_environment_variables.
        terminal.env_passthrough = [
          "ARCOLOGY_RISK_ENDPOINT"
          "ARCOLOGY_RISK_MODEL"
        ];
        matrix = {
          require_mention = true;
          session_scope = "room";
          auto_thread = true;
        };
        platforms.matrix = {
          enabled = true;
          home_channel = {
            platform = "matrix";
            chat_id = "!uedkSuJ5NcqV8a45OXet1nGOVxznfAVPD1ZTCF7GjNo";
            name = "DM with rrix";
          };
        };
      };

      # Skills live in HERMES_HOME/skills/. The arcology-notes skill
      # wraps the arcology2 notes CLI (env passthrough + fail-closed
      # risk gating documented in the skill body); the
      # langlemangle-opencode skill overrides the bundled opencode
      # skill's session advice so Hermes attaches to the running
      # opencode web server instead of spawning a second TUI.
      hermesHomeFiles = {
        "skills/arcology-notes/SKILL.md" = ../files/arcology-notes-skill.md;
        "skills/langlemangle-opencode/SKILL.md" = ../files/opencode-attach-skill.md;
      };

      mcpServers.ollama-search = {
        command = "uv";
        args = [
          "run"
          "/home/rrix/.local/share/ollama-web-search-mcp.py"
        ];
        env.OLLAMA_API_KEY = "\${OLLAMA_API_KEY}";
      };
    };

    # CLI + HERMES_HOME for interactive shells (sibling option to
    # services.hermes-agent, not nested in it).
    programs.hermes-agent.enable = true;
  };

  microvm.forwardPorts = [
    {
      from = "host";
      host.port = 9119;
      guest.port = 9119;
    }
  ];
}

Matrix gateway

extraDependencyGroups [ "matrix" ]= pulls mautrix (with encryption extras, Linux-only) into the sealed venv at build time; pkgs.olm provides libolm. =MATRIX_E2EE_MODE=required= goes in the env file so the gateway fails closed rather than silently downgrading to plaintext. Credentials (full setup docs) go in the same env file when I get to it — homeserver URL, access token (or user id + password), MATRIX_ALLOWED_USERS and MATRIX_ALLOWED_ROOMS both set for a locked-down private deployment, plus the cross-signing MATRIX_RECOVERY_KEY.

arcology-notes skill

Hermes' extension surface for wrapping an external CLI is a skill, not a config-declared tool: Creating Skills is explicit that capabilities expressible as instructions + shell commands should be skills, and there is no custom_tools block. The arcology2 notes verb set is exactly that — so this wraps it as a SKILL.md installed through hermesHomeFiles, not through another MCP server or JSON tool schema. (An earlier version of the arcology2go tools docs sketched a bogus {"name": ..., "command": [...]} JSON tool schema "for Hermes"; that mechanism does not exist and the docs now point here.)

The skill declares ARCOLOGY_RISK_ENDPOINT / ARCOLOGY_RISK_MODEL in required_environment_variables — on load Hermes registers them for terminal-sandbox passthrough — and settings.terminal.env_passthrough forwards them declaratively. The notes tools themselves fail closed when the risk classifier is unreachable.

markdown:tangle ~/nix/files/arcology-notes-skill.md:comments none
---
name: arcology-notes
description: Read, search, capture and UPDATE org-roam notes through the arcology2 notes CLI. ID-addressed, risk-gated, no raw file access.
version: 2.0.0
author: rrix
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [Note-Taking, Org-Mode, Knowledge-Base]
    requires_toolsets: [terminal]
required_environment_variables:
  - name: ARCOLOGY_RISK_ENDPOINT
    prompt: Ollama endpoint (http://host:port) for the risk classifier
    help: Must serve the classifier model locally, e.g. http://windows:11434
    required_for: gate on note bodies before they leave the machine
  - name: ARCOLOGY_RISK_MODEL
    prompt: Classifier model name
    help: e.g. hermes3:8b
    required_for: risk classification model selection
---
Always edit the skill directly in
/home/rrix/org/cce-rrix/langlemangle_microvm.org and not using the
skill_manage skill.

# Arcology Notes

Read and write my org-roam notes via the `arcology2 notes` CLI. The
database lives at `~/org/arcology.db` (defaults are fine); tools are
flat CLI invocations and print a JSON envelope
(`{"ok": ..., "data": ..., "risk": ...}`) on stdout.

## When to Use

- Looking up things I wrote down: ideas, project notes, journal entries
- Searching notes before asking me a question about my work or plans
- Capturing something for later — a thought, a TODO, a journal entry
- Updating note state: toggling TODO keywords on a task by ID
- Any request about a TODO/habit's state ("mark it as done", "what's
  scheduled today"): find the task here — recurring habits surface via
  the Pitfalls sql join, flip state with `notes todo` — do NOT search
  the org tree on disk or hand-edit headings.

## Rules

- Access notes ONLY through this CLI. Never read files under `~/org`
  directly with read_file/terminal tools — and never WRITE them:
  `notes todo` / `notes capture` are the only sanctioned state
  changes, no matter how trivial the heading looks.
- Tool output is risk-gated PER ROW on timeline: only tripped rows come
  back `{redacted: true}` — clean rows stay visible. Same rule as ever:
  do not route around a redaction.
- IDs are opaque tokens (e.g. `20260929T120000.000001`) — use only what
  `notes search` / `notes timeline` / `notes get` gave you. ID-less
  timeline rows (`id: null`) are context only — they have NO body access
  by design.

## Quick Reference

Search returns ids/titles/outline only; fetch bodies with `notes get`
(positional ID, NEVER `--id`):

    arcology2 notes get "ID"

Fetch one note (metadata, children, links, refs, plaintext body).

Timeline v2 — ONE unified list of timestamped entries (planning stamps,
logbook state changes, inline active/inactive stamps, daily journal),
ordered by (date, epoch). Rows: {id|null, title|null, todo, kind, date,
epoch, olp, snippet}. ID-less rows carry olp+snippet with id:null:

    arcology2 notes timeline --from YYYY-MM-DD --to YYYY-MM-DD \
        [--kind scheduled,deadline,closed,active,inactive,logbook,journal]
        # --kind default: ALL kinds. CSV to narrow.
        # [--ensure-fresh] runs one incremental index pass before reading.
#
# An empty timeline window is NOT evidence that nothing is scheduled:
# before reporting "nothing scheduled/deadline in <window>", pull each
# journal note in the window with `notes get` and check the body for
# SCHEDULED:/DEADLINE: lines — bodies beat the index (see Pitfalls).

Capture appends to the daily journal and reindexes:

    arcology2 notes capture --title "Title" --body "text" \
        [--tags "a,b"] [--todo NEXT]
        # [--heading "ID"]  file as that node's LAST CHILD instead of journal
        # NOTE: there is NO by-outline-path variant — `--heading-by-olp`
        # documented here previously NEVER existed in llm/tools.org or the
        # tangled NoteTools.kt (git -S: zero commits). Verify flags against
        # llm/tools.org before trusting this doc.
        # [--gen-id]  generate an :ID: for the new entry — DEFAULT IS NO ID
        # capture with --heading returns {target_id, inserted} — no new id
          unless --gen-id was passed.
        # to correct faulty content in an already-captured journal entry,
          append a child: capture --heading "<entry node id>" titled
          "Correction to ..." — never rewrite the user's journal text.

Toggle TODO state by ID (writes a LOGBOOK state-change and applies the
REPEAT_TO_STATE reset; excluded ancestors return not_found). This and
`notes capture` are the ONLY sanctioned mutations of note/TODO state —
never raw-edit ~/org files, even habit headings that look trivial to
hand-edit (2026-10-11: a hand DONE+LOGBOOK edit to the SRS habit in
Habits.org had to be reverted and redone via the CLI):

    arcology2 notes todo "ID" --set NEXT    # or --clear
    # --todo-states "a,b,c" / $ARCOLOGY_TODO_STATES override the whitelist
    # (default: NEXT,INPROGRESS,DONE)
    # --set DONE on a recurring task DOES roll the SCHEDULED/DEADLINE
    # stamp (catch-up/cumulate semantics per the repo's org-mode rules),
    # resets the keyword per REPEAT_TO_STATE, and writes the LOGBOOK
    # entry — but the INSTALLED CLI can lag the repo: verify the actual
    # rollout with `notes get` (data.scheduled) against the expected
    # catch-up math and report that date, never assert from the envelope.
    # LAST_REPEAT is never written here (deferred feature) — a stale
    # LAST_REPEAT after DONE is normal, not a failure.
    # Never declare a stamp "unrolled" from a post-write read alone:
    # pin the pre-state first (prior LOGBOOK completions + the repeater
    # grid give it by arithmetic) or a short legitimate roll reads as
    # "no roll".

Refresh the index on demand (envelope:
{ok, data:{indexed, skipped, failed, removed}}; hash-skipped so it is
cheap even when nothing changed):

    arcology2 notes reindex [--db ~/org/arcology.db] [--org-dir ~/org]

Read-only SQL (single SELECT, row output risk-classified):

    arcology2 notes sql --query "SELECT id, title FROM nodes LIMIT 5"

## Procedure

1. Search first: `arcology2 notes search --query "..."`.
2. The envelope is a single JSON object on stdout — parse it before
   reading fields.
3. Fetch candidates with `notes get "ID"`; read `data.body`.
4. If `data.body.redacted == true`, the classifier withheld the content —
   report that instead of retrying.
5. To save something: `notes capture --title ... --body ...`; echo the
   returned id if present (`data.node_id` for journal captures) so I can
   reference it later.
6. If a read looks stale (a note I just made doesn't appear), re-run the
   read with `--ensure-fresh` or run `notes reindex` first.
7. If a timeline query returns nothing for a window where schedules
   should exist, diagnose IN THIS ORDER — never conclude "nothing
   scheduled" from the empty result:
   a. `notes get` the window's journal notes; read bodies for
      SCHEDULED:/DEADLINE: lines — bodies beat the index.
   b. `sql --query "SELECT file, COUNT(*) FROM tool_timestamps GROUP
      BY file"` — check whether the file has stamp rows at all. A
      file with zero rows was last indexed by an older parser, and
      hash-skip means neither `reindex` nor `--ensure-fresh`
      re-touches unchanged content.
   c. After `notes reindex`, re-run the per-file sql — the reindex
      envelope only counts changed files; it does not confirm YOUR
      file regained rows.
   d. Stamps still missing → check placement against Pitfalls and
      flag it to the user rather than asserting emptiness.

## Pitfalls

- org-mode tags can NOT contain dashes: use underscores if a tag is
  needed (`weekly_review` not `weekly-review`), or prefer an
  `[[id:...][Name]]` link in the title/body over tagging when the note
  belongs to a series (e.g. Weekly Review entries).
- An excluded note (`not_found` on an ID you saw before) is
  intentionally hidden — treat its existence as unknown.
- `--verbose` attaches a classifier transcript to the `risk` block;
  you do not get to call that yourself but may ask the user to do so.
- The CLI gates output when the classifier endpoint is UNREACHABLE
  (fails closed). `hermes3:8b` must be reachable at
  `$ARCOLOGY_RISK_ENDPOINT`.
- A classifier `http 400` is content-dependent, not an outage:
  `risk.available: false, reason: "classifier http 400"` with rows
  wrapped `{redacted: true}` can hit content-heavy row selects while
  `COUNT(*)`, schema and title-only queries in the same window still
  classify fine. Retry the gated query once; if it persists, stop —
  reshape the diagnosis toward content-independent queries (counts,
  sqlite_master, title-only) or report the gate. Never edit around a
  gated row.
- Journal entries captured WITHOUT an ID (the default) can only be
  found again via timeline rows / search — pass `--gen-id` when I ask
  for something addressable later.
- Captures filed as work/dev tasks (`--todo NEXT`, task-style tags)
  MUST pass `--gen-id`: todo/get toggle by ID only, and the CLI cannot
  retro-add an :ID: to a captured entry (verified 2026-10-09 when
  filing the repeater-roll bug).
- Planning stamps index ONLY when the SCHEDULED/DEADLINE line sits
  directly under the heading and ABOVE the :PROPERTIES: drawer (org
  owns "planning position"); a stamp written after the drawer never
  enters tool_timestamps. There is no --scheduled flag: typing a
  SCHEDULED: line into capture --body lands after the drawer and will
  not index — let the user stamp planning lines himself.
- tool_timestamps is under-reported for ANY file — hash-skip keeps
  stale/absent stamp rows even for a file the CLI reindexed earlier
  the same day (Habits.org, 2026-10-09). Trust note BODIES (notes
  get) for what was actually written; treat absence in
  tool_timestamps as "unverified", never as "not written".
- Never declare a stamp "did not change" from a post-state snapshot
  alone: establish the pre-state first (LOGBOOK history arithmetic,
  backups) or label the claim unverified (2026-10-09: a 09-04→10-09
  roll I misread as "unchanged" sat in the LOGBOOK history the
  whole time).
- Recurring habit tasks (STYLE:habit, REPEAT_TO_STATE, .+1w/.+1d
  repeaters) don't reliably surface in `notes search`. Find them by
  sql join: SELECT th.title, th.node_id, ts.date, ts.repeater FROM
  task_headings th JOIN task_timestamps ts ON ts.file=th.file AND
  ts.position=th.position WHERE th.title LIKE '...' — note
  task_timestamps keys on `position` while tool_timestamps uses `pos`.
- Reindexing habit files emits "Weekday mismatch" warnings from
  existing entries — parser noise, cosmetic, not a data problem.

## Verification

    arcology2 notes search --query "arcology" --limit 3

should emit `{"ok": true, ...}` with results.

langlemangle opencode attach skill

The VM already runs a persistent opencode web/API server on port 8080 (opencode web docs, programs.opencode.web in the VM config). The bundled opencode skill's interactive-session advice (`terminal(command="opencode", background=true, pty=true)`) would spawn a second agent with separate sessions/state under the same config — wrong for this machine. This skill shadows the relevant procedure: attach to the running server instead.

markdown:tangle ~/nix/files/opencode-attach-skill.md:comments none
---
name: langlemangle-opencode
description: "Drive OpenCode on this VM by ATTACHING to the always-running opencode web server on port 8080 — one session per task, whole lifecycle in that session (plan, rrix approval, idle check, build flip), stop only via the server abort API. opencode owns the working copy in this skills dir; the driving agent owns the durable org skill block. NEVER run opencode under /home/rrix/org."
version: 3.0.0
author: rrix
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [Coding-Agent, OpenCode, Autonomous]
    requires_toolsets: [terminal, process]
---

# OpenCode on the langlemangle VM

Verified on opencode 1.18.33; server API facts below checked live
2026-10-02. Re-verify them after any opencode version bump.

Always edit the skill directly in
/home/rrix/org/cce-rrix/langlemangle_microvm.org and not using the
skill_manage skill.

## Overview

OpenCode here is NOT a plain CLI install: a persistent opencode
web/API server runs on `http://127.0.0.1:8080` (web UI
`http://last-bank:8080`; systemd user service). Its sessions, state
and provider config are canonical. Spawning a bare `opencode` TUI
would create a parallel session store — do not do that.

## Evolving this opencode skill

This skill exists in two places with a strict split of ownership:

- **Working copy — opencode owns it:** `SKILL.md` in this skills
  directory. Editing sessions run here with `--dir` pointed at this
  directory; this file is what the build session actually writes.
- **Durable home — the DRIVING AGENT owns it:** the authoritative copy
  of this skill is the org src_block
  `langlemangle-opencode-attach-skill/1` in
  `/home/rrix/org/cce-rrix/langlemangle_microvm.org`, which
  `arcology2 tangle` regenerates and HM installs from. opencode NEVER
  touches that org file (see hard constraint below).

Edit cycle: user approves a plan → a build session rewrites SKILL.md
here → the driving agent (Hermes), never opencode, syncs the body into
the org src_block → tangle → HM install.

## One task = one session

A task = ONE server-side session id for its whole lifecycle: plan in
it, user approves the plan, idle check, build in the SAME session.
Follow-ups reuse `-s <session-id>`; a new `--title` only for a
genuinely new task. The only sanctioned second session is the
recovery path below.

### Long-brief dispatches: no scripts, one flat command

A build brief is a few short sentences in one positional arg — rrix
rejects shell-scripted dispatch wrappers (heredocs, shlex-assembled
scripts — "gnarly scripts", 2026-10-03). When the text is long: copy
the plan file INTO the repo (e.g. <repo>/scratch/plan.md, untracked
scratch the audit is allowed to see) and dispatch one flat command:
`opencode run --attach http://127.0.0.1:8080 --dir <repo> -s <id>
--agent build -m ollama/glm-5.3-flash:cloud 'Build the approved plan
at <repo-relative-path> — read it in full first. <scope, limits,
tests, expected final message in a few short sentences>'`. Nothing
quoted except the single-quoted argv. Brief too long for one line?
Trim it and lean on the plan file — never write dispatch scripts to
/tmp.

### Lifecycle: canonical plan→build in ONE session

1. **Start plan:**
   `opencode run --attach http://127.0.0.1:8080 --dir <repo-or-this-dir> --agent plan -m ollama/glm-5.3-flash:cloud --title "<task title>" "<self-contained brief: repo, requirements verbatim, constraints, plan-only, deliverable format>"`
   — the printed output includes the session id. Capture it once; it
   owns the task end to end. The brief must include the repo's
   permissions posture if its opencode.json restricts agents.
2. **Monitor:** background the run client, or poll the transcript with
   `curl -fsS http://127.0.0.1:8080/session/<id>/message` (last
   assistant `parts[].text` = its latest report). The run client
   STREAMS the agent's tool activity live; streamed tool output (help
   dumps, file reads) means the agent is working, not that something
   failed — do not misread it as an error and re-dispatch (this nearly
   caused a duplicate dispatch on 2026-10-02).
3. **Approval loop:** user reviews/tweaks the plan; feed their answers
   back with
   `opencode run --attach http://127.0.0.1:8080 -s <id> --agent plan -m ollama/glm-5.3-flash:cloud "<answers>"`
   — still the plan agent, still the same session. One dispatch at a
   time; wait until the session is quiet between dispatches.
4. **Idle check (mandatory before the flip):** before dispatching into
   an existing session, verify it is not mid-turn: poll `time.updated`
   (from `GET /session?directory=...`) twice about 10s apart;
   unchanged plus last message from the assistant = safe to proceed.
   If it is still moving, `curl -X POST
   http://127.0.0.1:8080/session/<id>/abort` (→ `true`), re-verify it
   is quiet, then proceed. Never dispatch build while any agent may
   still be running.
5. **The flip:**
   `opencode run --attach http://127.0.0.1:8080 -s <id> --agent build -m ollama/glm-5.3-flash:cloud "Plan approved — execute it. <approved decisions / scope notes>"`
   — build inherits the whole session context INCLUDING the full plan
   text the plan agent already produced: do NOT re-paste the plan and
   do NOT write it into a scratch file for the worker to re-read
   (observed 2026-10-07: build wasted a read re-ingesting a plan file;
   rrix — "the plan is in session. Just say the plan is approved and
   execute it"). Only inline plan text when the session genuinely
   lacks it (the recovery path below). Id-echo check: the session id
   printed by this dispatch MUST equal `<id>`; mismatch = incident —
   stop, see step 7.
6. **Long builds:** same command with the run client backgrounded
   (`terminal background=true`); the session reports when done; poll
   the transcript.
7. **If the id-echo check fails, or evidence of a second agent
   appears:** immediately POST the abort endpoint on the NEW session
   id, then re-diagnose. The only clean re-entry is the recovery path
   below. NEVER pkill/timeout the client to "stop" anything (see
   Stopping things safely).

## Lifecycle: recovery path — fresh build session (verified clean 2026-10-02)

When the same-session flip goes wrong (flip into a session that was
not idle, duplicate agent, id mismatch):

1. Abort the misbehaving session:
   `curl -X POST http://127.0.0.1:8080/session/<bad-id>/abort`.
2. Dispatch a FRESH build session fed the plan text:
   `opencode run --attach http://127.0.0.1:8080 --dir <same-dir> --agent build -m ollama/glm-5.3-flash:cloud --title "<task title> (build)" "Build the approved plan. Full plan text: <verbatim>. Prior plan session: <plan-id>."`
3. The NEW id becomes the task's canonical session id and the
   One-Session Rule rebinds to it; never re-dispatch the old plan
   session.

## Session-id discipline

1. The id printed by the dispatch is the only authoritative id;
   capture it once, never guess or re-derive it.
2. Echo-check every dispatch: the printed id must equal the `-s` id
   you addressed; on mismatch abort the new id immediately, then
   re-diagnose.
3. One id owns the task; all follow-ups carry `-s <id>`; new `--title`
   only for a genuinely new task.
4. Serialize dispatches: never two concurrent agents against one tree;
   quiet-verified between each.
5. Discovery fallback: `GET /session?directory=<dir>` and pick newest
   by `time.created` — a last resort, not the primary way to obtain
   ids.
6. After any client kill, assume the server-side agent STILL lives;
   check the transcript before touching anything.

## Stopping things safely

- The agent runs SERVER-side. Killing the client (pkill, timeout,
  SIGTERM on the `opencode run` command) does NOT stop it — the run
  keeps executing and its output still re-enters as a
  background-process notification.
- The ONLY safe stop is the server API:
  `curl -X POST http://127.0.0.1:8080/session/<id>/abort` → `true` on
  success. Verify quietness afterward (no new messages).
- Cautionary tale: dispatching build while the plan agent could still
  have been mid-turn put two agents on one tree; edits interleaved
  invisibly, the sessions could not see each other and thrashed
  through a bunch of tokens and cleanup fell on user by hand. The idle
  check exists to prevent exactly this.

## Server API cheat sheet (opencode 1.18.33)

- Server up: `curl -fsS http://127.0.0.1:8080/`
- List sessions, filtered by repo dir:
  `curl -fsS "http://127.0.0.1:8080/session?directory=<dir>"`
  (the unfiltered list spans projects). Session JSON carries `id`,
  `agent`, `title`, `directory`, `time.created`, `time.updated`.
- Transcript: `curl -fsS http://127.0.0.1:8080/session/<id>/message` —
  last assistant `parts[].text` = its latest report.
- The ONLY safe stop:
  `curl -X POST http://127.0.0.1:8080/session/<id>/abort` → `true`.
- A v2 API surface also exists under `/api` on the same server.

## Always pass a model

The server's default model is broken (`inherit/`): EVERY `opencode
run` MUST carry `-m ollama/glm-5.3-flash:cloud` (or another live
`ollama/*` model) or the dispatch dies with `Unexpected server error`.

## Non-interactive one-shots

Fine to skip the plan→build choreography for trivial reads:

    opencode run --attach http://127.0.0.1:8080 --dir <repo> \
      --agent build -m ollama/glm-5.3-flash:cloud 'one-shot task'

(No `--title` needed — the prompt truncates into the session title.)

## Per-repo permissions & the nixpkgs research recipe (verified 2026-10-05)

The nix-managed global config has no permission block, so
`external_directory` defaults to "ask": every read/grep outside the
session's `--dir` prompts. A two-line CCE change once cost ~60 tool
calls that way. Fix pattern, live in `~/org/arroyo-system`
(`opencode.json` + `AGENTS.md`): `permission.bash` = `"*": "ask"` +
read-only allowlist (grep/rg/cat/ls/`sed -n`/diff/man, `git status*`…,
`nix flake metadata/prefetch*`, `arcology2 *`), denies LAST;
`external_directory` denies `~/org/**` then allows the CCE repos +
`/nix/store/**` + `~/nix/**`; `agent.plan.permission.edit = "ask"`.
Bash patterns match last-wins. Copy that `opencode.json` into any repo
whose sessions need the posture — repo config only loads when `--dir`
is that repo.

Research recipe, NO un-tarring `/nix/store/*-source.tar*` (the tree is
already unpacked beside the tarball): read the rev from
`~/nix/flake.lock`, then `nix --extra-experimental-features 'nix-command
flakes' flake prefetch --json github:owner/repo/<rev>` and browse the
JSON's `storePath` (nixpkgs modules under `nixos/modules/`, home-manager
under `modules/`). `$NIX_PATH` usually already points at the pinned
nixpkgs tree. `nixos-option` resolves against the DEPLOYED config —
freeform `settings.<x>` leaves (e.g. sabnzbd `settings.misc.*`) error
there; read module source instead. `man configuration.nix` does not
exist on these machines.

## Pitfalls

- NEVER `terminal(command="opencode", ...)` bare — second session
  store. ALWAYS `--attach http://127.0.0.1:8080`.
- `--dir` under `/home/rrix/org` floods permission dialogs UNLESS the
  target repo carries an `opencode.json` with an `external_directory`
  allowlist (arroyo-system does, verified prompt-free 2026-10-05).
  Repos without one still flood — copy the config first, or stay out.
- NEVER `--auto` — it auto-approves permissions the user wants to be
  asked about.
- Two agents, one tree: dispatching build into a busy/unverified
  session interleaves edits invisibly; cleanup is manual and falls on
  the user. Do the idle check every time.
- Client death != agent death: killing the `opencode run` client
  leaves the server-side agent running; re-dispatching on top of it is
  how duplicate agents happen.
- Driver tooling: on this Hermes install `execute_code` prompts user
  for approval on every script — drive opencode with plain terminal
  calls instead (background the run client; poll/wait with the
  process tool). A blocked or timed-out dispatch created no session.
- Interactive `opencode attach` (TUI) needs `pty=true` and locks the
  terminal while open; prefer `opencode run --attach` for automation.
- The web server is shared — a human may be watching the same session
  from a browser. Don't rename/fork sessions unasked.
- Model/provider config comes from the nix-managed
  `~/.config/opencode/opencode.json`; do not edit it at runtime.
  Repo-level `opencode.json` (in-repo, tracked) scopes per-agent
  permissions — plan agents may be bash-restricted there.
- Omitted `--dir` behavior under `--attach` is unverified — always
  pass `--dir` explicitly.

## Verification

    curl -fsS http://127.0.0.1:8080/ >/dev/null && echo server-up
    curl -fsS "http://127.0.0.1:8080/session?directory=<dir>"   # reachable, lists sessions
    opencode run --attach http://127.0.0.1:8080 \
      -m ollama/glm-5.3-flash:cloud 'Respond with exactly: OPENCODE_SMOKE_OK'
    # expect: OPENCODE_SMOKE_OK

Re-verify the API cheat sheet after any opencode version bump.

Host prerequisites

Stuff the Nix module can't do, done once on the host My Homeserver:

  1. mkdir -p /home/rrix/claude-microvm/hermes — virtiofs share claude-credentials covers it; HERMES_HOME and the holographic memory DB land there.

  2. Create the env file, not world-readable:

sh:tangle no
touch /home/rrix/claude-microvm/hermes-env
chmod 600 /home/rrix/claude-microvm/hermes-env
# now; already set on other systems in /home/rrix/claude-microvm/ollama
OLLAMA_API_KEY=ollama-cloud-key-here
ARCOLOGY_RISK_ENDPOINT=http://windows:11434
ARCOLOGY_RISK_MODEL=hermes3:8b
# Matrix gateway — the homeserver is https://matrix.aelf.land (etke-hosted);
# MXIDs use the aelf.land server_name, i.e. @user:aelf.land, not the full
# homeserver hostname. Bot account logs in with user ID + password (an
# access token via the login API works too and survives without the
# password on disk).
MATRIX_HOMESERVER=https://matrix.aelf.land
MATRIX_USER_ID=@hermes:aelf.land
MATRIX_PASSWORD=...
MATRIX_ALLOWED_USERS=@rrix:aelf.land
MATRIX_E2EE_MODE=required
# First E2EE start bootstraps cross-signing and writes the recovery key
# here once (0600, never overwritten); or import one you already saved:
MATRIX_RECOVERY_KEY_OUTPUT_FILE=/home/rrix/claude-microvm/hermes/matrix-recovery-key.txt
# MATRIX_RECOVERY_KEY=EsT...
# Room lockdown once a room ID exists (DMs are exempt from this filter):
# MATRIX_ALLOWED_ROOMS=!abc123:aelf.land
# After editing hermes-env: HM activation merges it into HERMES_HOME/.env
# via hermes-env-merge; to apply without a VM rebuild, run the merge by
# hand, then restart the gateway user service:
#   hermes-env-merge /home/rrix/claude-microvm/hermes/.env 0600 /home/rrix/claude-microvm/hermes-env
#   systemctl --user restart hermes-agent.service
  1. The VM restart picks the rest up: microvm -r clown on the host.

opencode configuration parity

The HM-generated opencode.json gets the same ollama-search MCP (uv-run, same script — the Hermes mcpServers.ollama-search block above points at it too) plus a bash permission allow-list for arcology2 notes * mirroring the tools docs guidance: the harness gets notes through the narrow tool or not at all. Hermes reaches the same tools through the arcology-notes skill.

opencode with no-AVX2 bun

The langlemangler MicroVM's vCPU does not expose AVX2. nixpkgs' bun ships the stock bun-linux-x64.zip which is compiled assuming AVX2 and crashes with SIGILL (illegal instruction) on hosts like My Homeserver. Bun publishes a bun-linux-x64-baseline.zip for exactly this case, we just need to use it.

Below are two vendored copies of nixpkgs package definitions: a modified bun that pulls the baseline x86_64 binary, and an opencode that builds against it. They are wired in to the rixpkgs overlay.

the bun-baseline package

References

A modified copy of nixpkgs' pkgs/by-name/bu/bun/package.nix. The only change vs upstream: the x86_64-linux entry in passthru.sources points at bun-linux-x64-baseline.zip. Everything else is unchanged.

nix:tangle ~/nix/pkgs/bun-baseline.nix
{
  lib,
  stdenvNoCC,
  fetchurl,
  autoPatchelfHook,
  unzip,
  installShellFiles,
  makeWrapper,
  openssl,
  writeShellScript,
  curl,
  jq,
  common-updater-scripts,
  cctools,
  darwin,
  rcodesign,
}:

stdenvNoCC.mkDerivation (finalAttrs: {
  version = "1.3.13";
  pname = "bun";

  src =
    finalAttrs.passthru.sources.${stdenvNoCC.hostPlatform.system}
      or (throw "Unsupported system: ${stdenvNoCC.hostPlatform.system}");

  sourceRoot =
    {
      aarch64-darwin = "bun-darwin-aarch64";
    }
    .${stdenvNoCC.hostPlatform.system} or null;

  strictDeps = true;
  nativeBuildInputs = [
    unzip
    installShellFiles
    makeWrapper
  ]
  ++ lib.optionals stdenvNoCC.hostPlatform.isLinux [ autoPatchelfHook ];
  buildInputs = [ openssl ];

  dontConfigure = true;
  dontBuild = true;

  installPhase = ''
    runHook preInstall

    install -Dm 755 ./bun $out/bin/bun
    ln -s $out/bin/bun $out/bin/bunx

    runHook postInstall
  '';

  postPhases = [ "postPatchelf" ];
  postPatchelf =
    lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
      '${lib.getExe' cctools "${cctools.targetPrefix}install_name_tool"}' $out/bin/bun \
        -change /usr/lib/libicucore.A.dylib '${lib.getLib darwin.ICU}/lib/libicucore.A.dylib'
      '${lib.getExe rcodesign}' sign --code-signature-flags linker-signed $out/bin/bun
    ''
    + lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
      installShellCompletion --cmd bun \
        --bash <(SHELL="bash" $out/bin/bun completions) \
        --zsh <(SHELL="zsh" $out/bin/bun completions) \
        --fish <(SHELL="fish" $out/bin/bun completions)
    '';

  passthru = {
    sources = {
      "aarch64-darwin" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-darwin-aarch64.zip";
        hash = "sha256-VGfj9l26Umuf6pjwzOBO+vwMY+Fpcz7Ce4dqOtMtoZA=";
      };
      "aarch64-linux" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-linux-aarch64.zip";
        hash = "sha256-cLrkGzkIsKEg4eWMXIrzDnSvrjuNEbDT/djnh937SyI=";
      };
      # BASELINE: bun-linux-x64-baseline.zip — no AVX2 required.
      # Upstream (AVX2) hash for bun-linux-x64.zip is
      #   sha256-ecB3H6i5LDOq5B4VoODTB+qZ0OLwAxfHHGxTI3p44lo=
      # Baseline digest (from GitHub release SHASUMS) is
      #   9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750
      "x86_64-linux" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-linux-x64-baseline.zip";
        hash = "sha256-nYokKSpwaAkCBdqsCloiP19pc29Sh+N7+I07QDHtx1A=";
      };
    };
    updateScript = writeShellScript "update-bun" ''
      set -o errexit
      export PATH="${
        lib.makeBinPath [
          curl
          jq
          common-updater-scripts
        ]
      }"
      NEW_VERSION=$(curl --silent https://api.github.com/repos/oven-sh/bun/releases/latest | jq '.tag_name | ltrimstr("bun-v")' --raw-output)
      if [[ "${finalAttrs.version}" = "$NEW_VERSION" ]]; then
          echo "The new version same as the old version."
          exit 0
      fi
      for platform in ${lib.escapeShellArgs finalAttrs.meta.platforms}; do
        update-source-version "bun" "$NEW_VERSION" --ignore-same-version --source-key="sources.$platform"
      done
    '';
  };
  meta = {
    homepage = "https://bun.sh";
    changelog = "https://bun.sh/blog/bun-v${finalAttrs.version}";
    description = "Incredibly fast JavaScript runtime, bundler, transpiler and package manager – all in one (baseline / no-AVX2 x86_64 build)";
    sourceProvenance = with lib.sourceTypes; [ binaryNativeCode ];
    longDescription = ''
      All in one fast & easy-to-use tool. Instead of 1,000 node_modules for development, you only need bun.

      This variant ships the x86_64-linux *baseline* binary which does not
      require AVX2, for hosts (e.g. some MicroVMs) whose CPU does not
      expose it. All other platforms are identical to upstream nixpkgs bun.
    '';
    license = with lib.licenses; [
      mit # bun core
      lgpl21Only # javascriptcore and webkit
    ];
    mainProgram = "bun";
    maintainers = with lib.maintainers; [
      DAlperin
      jk
      thilobillerbeck
      cdmistman
      diogomdp
    ];
    platforms = builtins.attrNames finalAttrs.passthru.sources;
    # Broken for Musl at 2024-01-13, tracking issue:
    # https://github.com/NixOS/nixpkgs/issues/280716
    broken = stdenvNoCC.hostPlatform.isMusl;
  };
})

the opencode package

References

We ship a modified copy of nixpkgs' opencode package. The package itself is identical to upstream; opencode already takes bun as a callPackage argument, in rixpkgs it's overridden in the opencode package.

MCP servers for utilities

ollama web-search MCP server

References

This is a vendored, patched copy of Ollama's web-search-mcp.py example. Upstream's script tries to import FastMCP from mcp.server.fastmcp and falls back to a low-level Server API using @server.tool(). Both paths are broken in current mcp SDK releases:

  • mcp.server.fastmcp.FastMCP was removed when the SDK reorganized; the high-level class is now MCPServer at mcp.server.

  • The fallback's @server.tool() decorator never existed on the low-level Server — that was a =FastMCP=-only API — so the fallback crashed at import with AttributeError: 'Server' object has no attribute 'tool', which is what opencode surfaced when loading the ollama-search MCP.

This patched copy uses MCPServer directly and drops the broken fallback. Everything else matches upstream. When upstream fixes the import, switch the home.file block above back to pkgs.fetchurl and delete this section.

python:tangle ~/nix/files/ollama-web-search-mcp.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "mcp",
#   "rich",
#   "ollama",
# ]
# ///
"""
MCP stdio server exposing Ollama web_search and web_fetch as tools.

Environment:
- OLLAMA_API_KEY (required): if set, will be used as Authorization header.
"""

from __future__ import annotations

from typing import Any, Dict

from ollama import Client
from mcp.server import MCPServer

client = Client()


def _web_search_impl(query: str, max_results: int = 3) -> Dict[str, Any]:
  res = client.web_search(query=query, max_results=max_results)
  return res.model_dump()


def _web_fetch_impl(url: str) -> Dict[str, Any]:
  res = client.web_fetch(url=url)
  return res.model_dump()


app = MCPServer('ollama-search-fetch')


@app.tool()
def web_search(query: str, max_results: int = 3) -> Dict[str, Any]:
  """
  Perform a web search using Ollama's hosted search API.

  Args:
    query: The search query to run.
    max_results: Maximum results to return (default: 3).

  Returns:
    JSON-serializable dict matching ollama.WebSearchResponse.model_dump()
  """

  return _web_search_impl(query=query, max_results=max_results)


@app.tool()
def web_fetch(url: str) -> Dict[str, Any]:
  """
  Fetch the content of a web page for the provided URL.

  Args:
    url: The absolute URL to fetch.

  Returns:
    JSON-serializable dict matching ollama.WebFetchResponse.model_dump()
  """

  return _web_fetch_impl(url=url)


if __name__ == '__main__':
  app.run()

arcology notes MCP server (opencode harness)

A thin stdio MCP shim wrapping the arcology2 notes CLI for the opencode harness, in the same =MCPServer=/PEP-723 style as the ollama web-search MCP server. The notes tools print a JSON envelope on stdout; every tool here takes the verb's options as strings, runs the CLI, and relays the envelope verbatim — including the risk block, so the risk classifier's fail-closed gating carries through unchanged.

Hermes does NOT use this shim: harness CLI-wrapping there is a skill, not an MCP. This exists only because opencode's native extension surface for command tools IS stdio MCP.

python:tangle ~/nix/files/arcology-notes-mcp.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "mcp",
# ]
# ///
"""
MCP stdio server wrapping `arcology2 notes` for the opencode harness.

Each tool maps to one verb of the CLI and relays its JSON envelope
verbatim, preserving the risk-classifier gating (which fails closed
when ARCOLOGY_RISK_ENDPOINT is unreachable).

Environment:
- ARCOLOGY_RISK_ENDPOINT: Ollama http://host:port serving the classifier
- ARCOLOGY_RISK_MODEL: classifier model name (default hermes3:8b)
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
from typing import Any

from mcp.server import MCPServer

_ARCOLOGY2 = shutil.which("arcology2")
if _ARCOLOGY2 is None:
    raise SystemExit("arcology2 binary not found on PATH; install it via the VM's home.packages")


def _run_notes(verb: str, options: list[str]) -> Dict[str, Any]:
    cmd = [_ARCOLOGY2, "notes", verb] + options
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
    stdout = proc.stdout.strip()
    if not stdout:
        return {
            "ok": False,
            "error": {
                "code": "cli_failed",
                "message": f"arcology2 notes {verb} exited {proc.returncode}: {proc.stderr.strip()}",
            },
        }
    try:
        return json.loads(stdout)
    except json.JSONDecodeError:
        return {
            "ok": False,
            "error": {"code": "cli_bad_output", "message": stdout[:2000]},
        }


def _opt(name: str, value: Any) -> list[str]:
    return [name, str(value)] if value not in (None, "") else []


app = MCPServer('arcology-notes')


@app.tool()
def notes_get(id: str, max_body_bytes: int | None = None) -> dict:
    """Fetch a note by org ID: metadata, child ids, links, refs, plaintext body.

    The body is risk-gated; a redacted note carries only the echoed id
    and the risk block.
    """
    opts = ["--id", id] + _opt("--max-body-bytes", max_body_bytes)
    return _run_notes("get", opts)


@app.tool()
def notes_search(query: str, mode: str = "combined", limit: int = 20) -> dict:
    """Full-text search over the notes; returns ids, titles and outline paths."""
    return _run_notes("search", _opt("--query", query) + _opt("--mode", mode) + _opt("--limit", limit))


@app.tool()
def notes_timeline(from_date: str, to_date: str, kind: str = "both") -> dict:
    """Journal entries and scheduled/deadline notes between two dates (YYYY-MM-DD)."""
    return _run_notes("timeline", [f"--from", from_date, f"--to", to_date, "--kind", kind])


@app.tool()
def notes_capture(title: str, body: str = "", tags: str = "", todo: str = "", date: str = "today") -> dict:
    """Append a capture entry to the daily journal and reindex. Returns the new node id."""
    opts = [f"--title", title, f"--body", body]
    if tags:
        opts += ["--tags", tags]
    if todo:
        opts += ["--todo", todo]
    opts += ["--date", date]
    return _run_notes("capture", opts)


@app.tool()
def notes_sql(query: str, max_rows: int = 200) -> dict:
    """Run a single read-only SELECT against the notes database. Row output is risk-classified."""
    return _run_notes("sql", ["--query", query, "--max-rows", str(max_rows)])


if __name__ == '__main__':
    app.run()