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.
microvm = {
url = "github:microvm-nix/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};a microvm for langlemangling
Linked from
https://github.com/microvm-nix/microvm.nix/blob/789c90b164b55b4379e7a94af8b9c01489024c18/doc/src/shares.md?plain=1#L39
{ 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:
{ 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
Linked from
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.
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
{ 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.
---
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.
---
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:
mkdir -p /home/rrix/claude-microvm/hermes— virtiofs shareclaude-credentialscovers it;HERMES_HOMEand the holographic memory DB land there.Create the env file, not world-readable:
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.serviceThe VM restart picks the rest up:
microvm -r clownon 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.
{
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.FastMCPwas removed when the SDK reorganized; the high-level class is nowMCPServeratmcp.server.The fallback's
@server.tool()decorator never existed on the low-levelServer— that was a =FastMCP=-only API — so the fallback crashed at import withAttributeError: 'Server' object has no attribute 'tool', which is what opencode surfaced when loading theollama-searchMCP.
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.
# /// 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.
# /// 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()