Initial commit: localgenai stack

Containerized local LLM stack for the Framework Desktop / Strix Halo,
plus the OpenCode harness on the Mac side.

- pyinfra/framework/: pyinfra deploy targeting the box
  - llama.cpp (Vulkan), vLLM (ROCm), Ollama (ROCm with HSA override
    for gfx1151), OpenWebUI
  - Beszel (host + container + AMD GPU dashboard via sysfs)
  - OpenLIT (LLM fleet metrics)
  - Phoenix (per-trace agent waterfall)
  - OpenHands (autonomous agent in a Docker sandbox)
- opencode/: OpenCode config + Phoenix bridge plugin (OTel exporter)
  - install.sh deploys to ~/.config/opencode/
- StrixHaloSetup.md / StrixHaloMemory.md / Roadmap.md / TODO.md:
  documentation and planning
- testing/qwen3-coder-30b/: small evaluation harness

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:35:10 -04:00
commit 2c4bfefa95
36 changed files with 5265 additions and 0 deletions

7
opencode/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Plugin deps — install.sh's `npm install` repopulates these.
.opencode/plugin/node_modules/
# Diagnostic log written by phoenix-bridge.js (Mac path).
/tmp/phoenix-bridge.log
.DS_Store

1685
opencode/.opencode/plugin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "localgenai-opencode-plugins",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "OpenCode plugins for the localgenai stack. Run `npm install` here once.",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.205.0",
"@opentelemetry/sdk-node": "^0.205.0",
"@opentelemetry/sdk-trace-base": "^2.0.0"
}
}

View File

@@ -0,0 +1,198 @@
// phoenix-bridge — register an OpenTelemetry SDK so the spans OpenCode
// already emits (when experimental.openTelemetry=true) actually leave
// the process and land in Arize Phoenix on the Framework Desktop.
//
// What it does:
// - Boots @opentelemetry/sdk-node with OTLP/HTTP exporter pointed at
// http://framework:4318/v1/traces (override via PHOENIX_OTLP_ENDPOINT).
// - Inserts @arizeai/openinference-vercel's OpenInferenceSpanProcessor so
// Phoenix's LLM/tool-aware UI gets richer attributes than vanilla OTel.
// - Opens a parent span around each OpenCode session, with sub-sessions
// (subagents spawned via the Task tool) nested under the parent. This
// gives Phoenix a unified tree per top-level conversation.
//
// Caveats (May 2026):
// - Subagent OTel context propagation across session boundaries is best
// effort; OpenCode's experimental.chat.system.transform doesn't expose
// sessionID yet (issue sst/opencode#6142). When that lands, swap the
// manual stitching here for proper traceparent injection.
// - Dynamic imports below are deliberate — per OpenCode's plugin guide,
// direct imports of optional deps freeze the harness if they're absent.
// Run `npm install` in this directory first.
export const PhoenixBridge = async ({ project, directory, worktree }) => {
// Direct file logging — OpenCode swallows stdout/stderr from plugin
// code, so this is how we get visibility into init progress. Tail with:
// tail -f /tmp/phoenix-bridge.log
const { appendFileSync } = await import("node:fs");
const log = (msg) => {
try {
appendFileSync(
"/tmp/phoenix-bridge.log",
`[${new Date().toISOString()}] ${msg}\n`,
);
} catch (_) {
/* best effort */
}
};
log("plugin function entered");
// Phoenix 15.x serves OTLP/HTTP at /v1/traces on the same port as the UI
// (6006). Earlier versions used a separate 4318 — override here if you
// ever pin Phoenix < 15.0.
const endpoint =
process.env.PHOENIX_OTLP_ENDPOINT || "http://framework:6006/v1/traces";
const serviceName = process.env.PHOENIX_SERVICE_NAME || "opencode";
// NodeSDK's `serviceName` constructor option is ignored in some
// versions; setting OTEL_SERVICE_NAME forces the resource attribute
// through the standard env-var path. Has to happen before sdk.start().
if (!process.env.OTEL_SERVICE_NAME) {
process.env.OTEL_SERVICE_NAME = serviceName;
}
log(`endpoint=${endpoint} serviceName=${serviceName}`);
// Dynamic imports so a missing dep produces a warning, not a freeze.
let NodeSDK,
OTLPTraceExporter,
BatchSpanProcessor,
SimpleSpanProcessor,
trace,
context,
diag,
DiagLogLevel;
try {
({ NodeSDK } = await import("@opentelemetry/sdk-node"));
// Use the protobuf-over-HTTP exporter — Phoenix's OTLP receiver
// only speaks protobuf (returns 415 for the JSON variant from
// @opentelemetry/exporter-trace-otlp-http).
({ OTLPTraceExporter } = await import(
"@opentelemetry/exporter-trace-otlp-proto"
));
({ BatchSpanProcessor, SimpleSpanProcessor } = await import(
"@opentelemetry/sdk-trace-base"
));
({ trace, context, diag, DiagLogLevel } = await import(
"@opentelemetry/api"
));
log("OTel imports resolved");
} catch (err) {
console.warn(
"[phoenix-bridge] OTel deps not installed — skipping. " +
"Run `npm install` in .opencode/plugin/ to enable.",
err && err.message,
);
return {};
}
// Pipe OTel's internal diagnostics into our log so export errors are
// visible. Without this, BatchSpanProcessor swallows exporter failures.
// Default to WARN — bump to DEBUG via PHOENIX_OTEL_DEBUG=1 when chasing
// a regression.
const otelLevel =
process.env.PHOENIX_OTEL_DEBUG === "1"
? DiagLogLevel.DEBUG
: DiagLogLevel.WARN;
diag.setLogger(
{
verbose: (m) => log(`[otel:verbose] ${m}`),
debug: (m) => log(`[otel:debug] ${m}`),
info: (m) => log(`[otel:info] ${m}`),
warn: (m) => log(`[otel:warn] ${m}`),
error: (m) => log(`[otel:error] ${m}`),
},
otelLevel,
);
// NodeSDK accepts `serviceName` directly, sidestepping the Resource API
// (which broke between @opentelemetry/resources v1.x and v2.x).
// SimpleSpanProcessor (vs BatchSpanProcessor) exports each span
// immediately — easier to debug while we sort out the pipeline.
const exporter = new OTLPTraceExporter({ url: endpoint });
const sdk = new NodeSDK({
serviceName,
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
sdk.start();
log("sdk.start() returned");
const tracer = trace.getTracer("opencode.phoenix-bridge");
log("tracer obtained");
// Smoke-test span. If this one doesn't show up in Phoenix, the export
// path is broken and there's no point chasing missing session spans.
// Should appear under service "opencode" within ~5 s of OpenCode start.
const bootSpan = tracer.startSpan("phoenix-bridge.boot", {
attributes: {
"opencode.endpoint": endpoint,
"opencode.service_name": serviceName,
},
});
bootSpan.end();
log("boot span emitted (will flush within ~5s)");
// sessionId -> { span, ctx }
const sessions = new Map();
const closeSession = (sessionId) => {
const rec = sessions.get(sessionId);
if (!rec) return;
rec.span.end();
sessions.delete(sessionId);
};
const onShutdown = async () => {
for (const id of [...sessions.keys()]) closeSession(id);
try {
await sdk.shutdown();
} catch (_) {
/* ignore */
}
};
process.once("beforeExit", onShutdown);
process.once("SIGINT", onShutdown);
process.once("SIGTERM", onShutdown);
return {
event: async ({ event }) => {
if (!event || !event.type) return;
switch (event.type) {
case "session.created": {
const session = event.properties?.session || event.properties || {};
const id = session.id;
const parentID = session.parentID;
if (!id) return;
const parentRec = parentID ? sessions.get(parentID) : null;
const parentCtx = parentRec ? parentRec.ctx : context.active();
const span = tracer.startSpan(
parentID ? `subagent ${id}` : `session ${id}`,
{
attributes: {
"opencode.session.id": id,
"opencode.session.parent_id": parentID || "",
"opencode.directory": directory || "",
"opencode.worktree": worktree || "",
"opencode.project": project?.name || "",
},
},
parentCtx,
);
sessions.set(id, { span, ctx: trace.setSpan(parentCtx, span) });
break;
}
case "session.idle":
case "session.deleted":
case "session.compacted": {
const id =
event.properties?.session?.id || event.properties?.id || null;
if (id) closeSession(id);
break;
}
default:
break;
}
},
};
};

138
opencode/README.md Normal file
View File

@@ -0,0 +1,138 @@
# opencode setup
Canonical OpenCode config + Phoenix bridge plugin for the localgenai
stack. `install.sh` deploys it to `~/.config/opencode/` on a Mac.
## What's wired up
- **Local model**: `framework/qwen3-coder:30b` served by Ollama on the
Framework Desktop, reachable over Tailscale.
- **Playwright MCP** ([@playwright/mcp](https://github.com/microsoft/playwright-mcp)) —
browser automation. The model can navigate pages, click, fill forms,
read DOM snapshots. Closes the agentic-browsing gap.
- **SearXNG MCP** ([mcp-searxng](https://github.com/ihor-sokoliuk/mcp-searxng)) —
web search via your self-hosted instance at <https://searxng.n0n.io>.
No external API keys, no rate-limit roulette.
- **Phoenix bridge plugin** (`.opencode/plugin/phoenix-bridge.js`) —
exports OpenTelemetry spans for every LLM call, tool call, and
subagent invocation to the Phoenix container running on the Framework
Desktop. Per-prompt waterfall / flamegraph viz at
<http://framework:6006>.
## Setup
```sh
./install.sh
```
Idempotent — re-run after editing `opencode.json` or pulling changes to
the plugin. Each step checks before doing work. Specifically:
1. Verifies Homebrew is present (won't install it for you)
2. `brew install node uv jq sst/tap/opencode` (skips if already at latest)
3. Pre-caches Playwright's chromium so the first MCP call is instant
4. `npm install` in `.opencode/plugin/` for the Phoenix bridge OTel deps
5. Generates `~/.config/opencode/opencode.json` from the repo's
`opencode.json`, rewriting relative plugin paths to absolute so
OpenCode loads the plugin regardless of which directory it's launched
from
Step 5 is the reason the deployed config isn't a plain symlink. The
repo's `opencode.json` uses a relative plugin path (`./...`) so it stays
valid in place; the deployed copy is generated with that path resolved
to an absolute one. Edits to the repo's `opencode.json` need a re-run
of `./install.sh` to take effect.
## Verify
```sh
# Local model reachable
curl -s http://framework:11434/v1/models | jq '.data[].id'
# SearXNG instance answers JSON
curl -s 'https://searxng.n0n.io/search?q=test&format=json' | jq '.results | length'
```
Then in opencode:
```
opencode
> /mcp # should list playwright and searxng as connected
> search the web for "qwen3-coder benchmarks"
> open https://example.com and tell me the H1
```
## Phoenix tracing
The plugin at `.opencode/plugin/phoenix-bridge.js` boots an OpenTelemetry
SDK on OpenCode startup and ships every span to Phoenix on the Framework
Desktop. With `experimental.openTelemetry: true` (already set in
`opencode.json`), OpenCode emits Vercel AI SDK spans that Phoenix renders
as a per-turn waterfall: user prompt → main agent's `ai.streamText`
each tool call (built-in + MCP) with token counts and latencies inline.
The plugin uses `@opentelemetry/exporter-trace-otlp-proto` (not `-http`)
because Phoenix's OTLP receiver only speaks protobuf — the JSON variant
returns 415.
Defaults can be overridden via env vars (set before launching opencode):
| Variable | Default | Purpose |
|---|---|---|
| `PHOENIX_OTLP_ENDPOINT` | `http://framework:6006/v1/traces` | OTLP/HTTP target |
| `PHOENIX_SERVICE_NAME` | `opencode` | Phoenix project name |
| `PHOENIX_OTEL_DEBUG` | unset | `1` to surface OTel internal logs |
### Verifying
```sh
: > /tmp/phoenix-bridge.log # truncate prior runs
opencode # any directory; CWD doesn't matter
tail -f /tmp/phoenix-bridge.log
```
Healthy startup looks like:
```
plugin function entered
endpoint=http://framework:6006/v1/traces serviceName=opencode
OTel imports resolved
sdk.start() returned
tracer obtained
boot span emitted (will flush within ~5s)
```
Then open <http://framework:6006/projects> — an `opencode` project should
appear with at least one `phoenix-bridge.boot` span. Send a prompt in
OpenCode and real LLM-call traces follow.
If the plugin's deps aren't installed, OpenCode logs a warning and the
plugin no-ops — the rest of OpenCode still works fine.
### Known limitations
- **Subagent nesting is best-effort.** The plugin opens a parent span
per session and tries to stitch child sessions (Task-tool subagents)
under their parent, but Vercel AI SDK spans live in their own OTel
trace context. Until [sst/opencode#6142](https://github.com/sst/opencode/issues/6142)
exposes `sessionID` in the `chat.system.transform` hook, child-session
spans may show as separate traces in Phoenix.
- **Console output from plugins is swallowed by OpenCode's TUI.** That's
why init progress goes to `/tmp/phoenix-bridge.log` rather than stdout.
## Notes
- **SearXNG JSON output** must be enabled on the instance for the MCP
server to work. If `format=json` returns HTML or 403, edit
`settings.yml` on the SearXNG box: `search.formats: [html, json]`,
restart.
- **Playwright first-run** downloads ~200 MB of browser binaries into
`~/Library/Caches/ms-playwright/`. Subsequent runs are instant.
- **Tool-calling reliability** with Qwen3-Coder is decent but not
Claude-grade. If a tool call hangs or returns malformed JSON, the
model is the culprit, not the MCP. Worth trying the same prompt
against a hosted Claude or GPT-5 to confirm before debugging the
server.
- **Adding more MCP servers**: drop another entry under the `mcp` key
using the same `type/command/enabled` shape. The
[official MCP registry](https://registry.modelcontextprotocol.io/)
and [Awesome MCP Servers](https://mcpservers.org/) catalog options.

145
opencode/install.sh Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Bootstrap or re-sync the OpenCode harness on this Mac.
#
# Idempotent — re-run after pulling changes to opencode.json or the
# Phoenix bridge plugin. Each step checks before doing work.
#
# What this does:
# 1. Verify Homebrew is present
# 2. Install node, uv, opencode, jq (skips if already at latest)
# 3. Pre-cache Playwright's chromium so the first MCP call is instant
# 4. Install the Phoenix bridge plugin's OTel deps
# 5. Generate ~/.config/opencode/opencode.json from the repo's
# opencode.json with relative plugin paths rewritten to absolute,
# so opencode loads the plugin regardless of where it's launched.
#
# Usage: ./install.sh (from this directory)
set -euo pipefail
cd "$(dirname "$0")"
HERE="$(pwd)"
# --- Pretty printing ---------------------------------------------------------
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
info() { printf ' → %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
fail() { printf ' \033[31m✗\033[0m %s\n' "$*"; exit 1; }
# --- 1. Homebrew -------------------------------------------------------------
bold "[1/5] Homebrew"
if ! command -v brew >/dev/null 2>&1; then
fail "brew not found. Install from https://brew.sh, then re-run."
fi
ok "brew $(brew --version | head -1 | awk '{print $2}')"
# --- 2. CLI deps -------------------------------------------------------------
bold "[2/5] CLI dependencies"
brew_install_if_missing() {
local pkg="$1"
local bin="${2:-$1}"
if command -v "$bin" >/dev/null 2>&1; then
ok "$pkg already installed ($(command -v "$bin"))"
else
info "installing $pkg"
brew install "$pkg"
ok "$pkg installed"
fi
}
brew_install_if_missing node node
brew_install_if_missing uv uv
brew_install_if_missing jq jq
# opencode is in a tap; check the binary, not the formula name.
if command -v opencode >/dev/null 2>&1; then
ok "opencode already installed ($(command -v opencode))"
else
info "tapping sst/tap and installing opencode"
brew install sst/tap/opencode
ok "opencode installed"
fi
# --- 3. Playwright browsers --------------------------------------------------
bold "[3/5] Playwright browser cache"
PW_CACHE="${HOME}/Library/Caches/ms-playwright"
if [[ -d "$PW_CACHE" ]] && find "$PW_CACHE" -name "chrome" -o -name "Chromium*" 2>/dev/null | grep -q .; then
ok "browsers already cached at $PW_CACHE"
else
info "downloading chromium (~200 MB) — first run only"
npx -y @playwright/mcp@latest --help >/dev/null 2>&1 || true
ok "browsers cached"
fi
# --- 4. Phoenix bridge plugin deps ------------------------------------------
bold "[4/5] Phoenix bridge plugin deps"
if [[ -d ".opencode/plugin/node_modules" && -f ".opencode/plugin/package-lock.json" ]]; then
# Re-run npm install if package.json is newer than the lockfile, otherwise skip.
if [[ ".opencode/plugin/package.json" -nt ".opencode/plugin/package-lock.json" ]]; then
info "package.json is newer than lockfile — running npm install"
( cd .opencode/plugin && npm install )
ok "deps updated"
else
ok "deps already installed"
fi
else
info "installing OTel deps (one-time, ~40 MB)"
( cd .opencode/plugin && npm install )
ok "deps installed"
fi
# --- 5. Generate ~/.config/opencode/opencode.json ---------------------------
# The repo's opencode.json uses relative plugin paths so it stays valid
# in-place. Rewriting them to absolute paths here makes opencode find the
# plugin regardless of which directory it was launched from. Re-run this
# script after editing opencode.json.
bold "[5/5] Deploy global config"
mkdir -p "${HOME}/.config/opencode"
src="${HERE}/opencode.json"
dst="${HOME}/.config/opencode/opencode.json"
# If the user previously had a symlink from the old install.sh, replace it.
if [[ -L "$dst" ]]; then
info "removing stale symlink at $dst"
rm "$dst"
fi
# And the old .opencode dir symlink — no longer needed now that plugin
# paths are absolute.
if [[ -L "${HOME}/.config/opencode/.opencode" ]]; then
info "removing stale ~/.config/opencode/.opencode symlink"
rm "${HOME}/.config/opencode/.opencode"
fi
# Rewrite any relative plugin path (./foo, ../foo) to an absolute path
# rooted at this directory. Absolute paths and npm-package refs pass
# through untouched.
jq --arg here "$HERE" '
.plugin = (
(.plugin // [])
| map(
if type == "string" and (startswith("./") or startswith("../"))
then ($here + "/" + ltrimstr("./") | gsub("/\\./"; "/"))
else .
end
)
)
' "$src" > "$dst.tmp"
mv "$dst.tmp" "$dst"
ok "wrote $dst"
info "plugin paths resolved to:"
jq -r '.plugin[]?' "$dst" | sed 's/^/ /'
echo
bold "Done."
cat <<EOF
Re-run this script after editing opencode.json — the deployed copy at
~/.config/opencode/opencode.json is generated, not symlinked.
Next steps:
- Verify the model server: curl -s http://framework:11434/v1/models | jq '.data[].id'
- Verify Phoenix is up: curl -sf http://framework:6006/
- Run opencode and send one prompt. A trace should appear at
http://framework:6006 within a couple of seconds.
- If nothing appears, check ~/.local/share/opencode/log/*.log for a
line starting with "[phoenix-bridge]".
EOF

41
opencode/opencode.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"openTelemetry": true
},
"plugin": ["./.opencode/plugin/phoenix-bridge.js"],
"provider": {
"framework": {
"npm": "@ai-sdk/openai-compatible",
"name": "Framework Desktop (Strix Halo)",
"options": {
"baseURL": "http://framework:11434/v1"
},
"models": {
"qwen3-coder:30b": {
"name": "Qwen3 Coder 30B (local)",
"limit": {
"context": 131072,
"output": 16384
}
}
}
}
},
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest"],
"enabled": true
},
"searxng": {
"type": "local",
"command": ["npx", "-y", "mcp-searxng"],
"enabled": true,
"environment": {
"SEARXNG_URL": "https://searxng.n0n.io"
}
}
},
"model": "framework/qwen3-coder:30b"
}