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:
1685
opencode/.opencode/plugin/package-lock.json
generated
Normal file
1685
opencode/.opencode/plugin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
opencode/.opencode/plugin/package.json
Normal file
13
opencode/.opencode/plugin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
198
opencode/.opencode/plugin/phoenix-bridge.js
Normal file
198
opencode/.opencode/plugin/phoenix-bridge.js
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user