commit 4f98428e1c1c5a8280f5cd847f92a6fae1511db0 Author: hzhang Date: Sun May 31 16:44:35 2026 +0100 feat: Plexum-gemini-provider v0.1 — Google Gemini via local CLI binary Plexum ProviderPlugin that wraps the local `gemini` CLI binary (Google Gemini CLI ≥0.37). Same CLI-driven approach as Plexum-anthropic-provider's contractor mode: each Plexum turn forks one `gemini -p` subprocess in the agent's workspace dir. Reference: openclaw/extensions/google/cli-backend.ts. internal/runner/ (~190 LOC): - Run() spawns: gemini --skip-trust --output-format json [--model X] [--resume ] -p in agent workspace - Parses the single JSON blob gemini emits at exit ({"session_id":"...", "response":"...", "stats":{...}}) - session_id persisted at /.plexum-gemini-session so the next turn passes --resume — multi-turn context continuity through the CLI's own session state - Emits synthetic message_start + text_delta + message_end events to match Plexum's streaming agentic-loop contract - Tolerates the "Ripgrep is not available." stderr preamble (strips leading non-{ bytes before json.Unmarshal) cmd/plexum-gemini-provider-plugin/ implements ProviderPluginWithAgent (receives AgentContext.Workspace from the host). Models advertised in provider.models: gemini (no --model flag; CLI default = flash-preview) gemini-pro → CLI alias "pro" → gemini-3.1-pro-preview gemini-flash → CLI alias "flash" → gemini-3.1-flash-preview gemini-flash-lite → CLI alias "flash-lite" → gemini-3.1-flash-lite-preview Unrecognized model id passes through as --model so operator can pin a specific gemini-3.x id. HostConfig (all optional): binary (default "gemini" — operator should set absolute path if running under systemd; PATH won't include nvm dirs) extra_args (appended before -p) No api_key field — gemini CLI handles auth via ~/.gemini/ state (OAuth or GEMINI_API_KEY env). End-to-end verified against local install: 1. CLI embedded turn 1: "Hi, I'm Gemini, your autonomous CLI agent for software engineering tasks." 2. CLI embedded turn 2: "I said hi as Gemini." (multi-turn ✓ via --resume) 3. Gateway socket: {"outcome":"text","text":"pong"} 4. Fabric channel e2e: alice → bt2-clean → gem agent → gemini CLI → outbound REST → seq=19: "A shrimp's heart is located in its head, which is quite an unusual biological arrangement." Known: when daemon runs under systemd, PATH doesn't include nvm dirs by default. Operator must set the absolute binary path in config.json (README notes this). Co-Authored-By: Claude Opus 4.7 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fca08d --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Plexum-gemini-provider + +Plexum ProviderPlugin that wraps the local `gemini` CLI binary +(Google Gemini CLI) — same CLI-driven pattern as +`Plexum-anthropic-provider`'s contractor mode. Each Plexum turn forks +one `gemini -p` subprocess in the agent's workspace. + +Reference: `openclaw/extensions/google/cli-backend.ts`. + +## Status + +**v0.1**: subprocess-per-turn, JSON output parsing, per-workspace +session continuity via `--resume`. Models: `gemini` (CLI default), +`gemini-pro`, `gemini-flash`, `gemini-flash-lite`. + +## Install + +```bash +cd ~/Plexum-gemini-provider +./scripts/install.sh +``` + +Then: + +1. **Allow** in `~/.plexum/plexum.json`: + ```json + {"plugins": {"allow": ["plexum-gemini-provider"]}} + ``` + +2. **Bind an agent**: + ```bash + plexum agent-add --model gemini-flash my-agent + ``` + +3. **Restart** + talk: + ```bash + systemctl --user restart plexum + plexum say --agent-id my-agent --session-id $(plexum new-session --agent-id my-agent) "hello" + ``` + +## Auth + +There is no API key in this plugin's config. The `gemini` CLI handles +auth itself via `~/.gemini/` (OAuth or `GEMINI_API_KEY`). Run +`gemini auth login` (or set env) once, then this plugin just shells +out. + +## How it works + +For each Plexum turn the plugin forks: + +``` +gemini --skip-trust --output-format json [--model ] [--resume ] -p "" +``` + +with `cwd = `. The CLI exits with a single JSON blob: + +```json +{ + "session_id": "uuid", + "response": "...", + "stats": {"models": {...}} +} +``` + +We capture `session_id` into `/.plexum-gemini-session` so the +next turn passes `--resume` and Gemini sees the full prior context. The +`response` text is emitted as one `text_delta` followed by `message_end`. + +## Models + +| Plexum model id | CLI invocation | +|---|---| +| `gemini` | no `--model` flag (CLI default — currently `gemini-3-flash-preview`) | +| `gemini-pro` | `--model pro` (CLI alias → `gemini-3.1-pro-preview`) | +| `gemini-flash` | `--model flash` (CLI alias → `gemini-3.1-flash-preview`) | +| `gemini-flash-lite` | `--model flash-lite` (CLI alias → `gemini-3.1-flash-lite-preview`) | + +Any other id is passed through as `--model ` verbatim so operators +can pin a specific Gemini model the local CLI knows about. + +## Config options + +| Field | Default | Notes | +|---|---|---| +| `binary` | `gemini` | executable name or absolute path | +| `extra_args` | `[]` | appended after the standard flags, before `-p` | + +## License + +Same as Plexum. diff --git a/cmd/plexum-gemini-provider-plugin/main.go b/cmd/plexum-gemini-provider-plugin/main.go new file mode 100644 index 0000000..0d19050 --- /dev/null +++ b/cmd/plexum-gemini-provider-plugin/main.go @@ -0,0 +1,147 @@ +// plexum-gemini-provider-plugin is a Plexum ProviderPlugin that wraps +// the local `gemini` CLI binary (Google Gemini CLI). Each Plexum turn +// forks one `gemini -p` subprocess in the agent's workspace, captures +// JSON output, and emits canonical TurnEvents. Multi-turn continuity +// via `--resume ` (session id stashed at +// /.plexum-gemini-session). +// +// Reference: openclaw/extensions/google/cli-backend.ts. Same CLI-driven +// approach as Plexum-anthropic-provider's contractor mode. +// +// Models — Plexum agent.json.model can be any of: +// gemini-pro → CLI alias "pro" → gemini-3.1-pro-preview +// gemini-flash → CLI alias "flash" → gemini-3.1-flash-preview (default) +// gemini-flash-lite → CLI alias "flash-lite" → gemini-3.1-flash-lite-preview +// gemini → no --model flag (gemini-cli default) +// or any raw model id the local gemini CLI accepts (passed through). +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" + plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" + + "git.hangman-lab.top/hzhang/Plexum-gemini-provider/internal/runner" +) + +const pluginName = "plexum-gemini-provider" + +// Aliases the plugin translates before passing --model to the CLI. +// CLI accepts the aliases directly too, but normalizing keeps logs +// consistent across operator + provider sides. +var modelAliases = map[string]string{ + "gemini-pro": "pro", + "gemini-flash": "flash", + "gemini-flash-lite": "flash-lite", +} + +// supportedModels: what the manifest's provider.models advertises. +// Operator agent.json.model must match one of these; we route the +// "gemini" plain id to the CLI's default (no --model flag). +var supportedModels = []string{ + "gemini", + "gemini-pro", + "gemini-flash", + "gemini-flash-lite", +} + +// HostConfig is per-profile plugin config at +// /plugins/plexum-gemini-provider/config.json. All fields +// optional — sensible defaults if file is missing. +// +// { +// "binary": "gemini", // executable name or absolute path +// "extra_args": ["--yolo"] // appended after the standard flags +// } +// +// No api_key field: gemini CLI auth lives in ~/.gemini (OAuth or +// GEMINI_API_KEY env), already set up by the operator. +type HostConfig struct { + Binary string `json:"binary"` + ExtraArgs []string `json:"extra_args"` +} + +type geminiPlugin struct { + host plugin.HostAPI + cfg HostConfig +} + +func (p *geminiPlugin) Manifest() plugin.Manifest { + return plugin.Manifest{ + Name: pluginName, + Version: "0.1.0", + Activation: plugin.ActivationLazy, + Executable: "plexum-gemini-provider-plugin", + Contracts: plugin.Contracts{ + Provider: &plugin.ProviderContract{Models: supportedModels}, + }, + } +} + +func (p *geminiPlugin) Init(ctx context.Context, host plugin.HostAPI) error { + p.host = host + + profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT") + if profileRoot == "" { + home, _ := os.UserHomeDir() + profileRoot = filepath.Join(home, ".plexum") + } + cfgPath := filepath.Join(profileRoot, "plugins", pluginName, "config.json") + raw, err := os.ReadFile(cfgPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", cfgPath, err) + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, &p.cfg); err != nil { + return fmt.Errorf("parse %s: %w", cfgPath, err) + } + } + if p.cfg.Binary == "" { + p.cfg.Binary = "gemini" + } + host.Log("info", "gemini provider initialized", map[string]any{ + "binary": p.cfg.Binary, "extra_args": p.cfg.ExtraArgs, "models": supportedModels, + }) + return nil +} + +// Stream is the bare ProviderPlugin entrypoint; called when the SDK +// can't see StreamWithAgent. We use the agent-aware variant because +// the CLI needs the workspace cwd. +func (p *geminiPlugin) Stream(ctx context.Context, modelID string, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) { + return p.StreamWithAgent(ctx, modelID, plugin.AgentContext{}, req) +} + +// StreamWithAgent receives the AgentContext (workspace path) Plexum's +// host wires in for this kind of plugin (see ProviderPluginWithAgent). +func (p *geminiPlugin) StreamWithAgent(ctx context.Context, modelID string, agent plugin.AgentContext, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) { + cliModel := mapModel(modelID) + return runner.Run(ctx, p.host, agent, cliModel, p.cfg.Binary, p.cfg.ExtraArgs, req) +} + +// mapModel converts a Plexum-advertised model id into the CLI's +// --model value. Returns "" for "gemini" (use CLI default) and +// passes any other unrecognized id through verbatim (operator may +// have configured a specific gemini-3.x model id directly). +func mapModel(plexumModel string) string { + if plexumModel == "" || plexumModel == "gemini" { + return "" + } + if alias, ok := modelAliases[plexumModel]; ok { + return alias + } + return plexumModel +} + +func main() { + if err := plugin.Serve(&geminiPlugin{}); err != nil { + fmt.Fprintf(os.Stderr, "plexum-gemini-provider-plugin: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9473d6a --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.hangman-lab.top/hzhang/Plexum-gemini-provider + +go 1.24.2 + +require git.hangman-lab.top/hzhang/Plexum-sdk-go v0.0.0 + +replace git.hangman-lab.top/hzhang/Plexum-sdk-go => ../Plexum-sdk-go diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..4f97df9 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,236 @@ +// Package runner forks the `gemini` CLI per Plexum turn and translates +// its JSON output into canonical TurnEvents. +// +// Each Stream call spawns one subprocess: +// +// gemini --skip-trust --output-format json [--model ] [--resume ] -p +// +// in the agent's workspace dir. The CLI emits a single JSON blob at +// exit; we parse it, capture session_id (for next-turn --resume), and +// emit synthetic message_start + text_delta + message_end events to +// match Plexum's streaming agentic-loop expectations. +// +// Session continuity: per-workspace UUID stored at +// /.plexum-gemini-session, used as --resume on subsequent +// turns. Mirrors the contractor pattern in Plexum-anthropic-provider. +package runner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" + plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" +) + +// SessionFile is the per-workspace filename storing the captured +// session id between turns. +const SessionFile = ".plexum-gemini-session" + +// Run is the ProviderPlugin Stream implementation. It forks gemini, +// waits for completion, and pushes events on the returned channel. +// Channel closes when the subprocess exits (clean or error). +func Run( + ctx context.Context, + host plugin.HostAPI, + agent plugin.AgentContext, + model string, + binary string, + extraArgs []string, + req canonical.TurnRequest, +) (<-chan canonical.TurnEvent, error) { + workspace := agent.Workspace + if workspace == "" { + return nil, errors.New("gemini: agent workspace required (host must supply AgentContext)") + } + if err := os.MkdirAll(workspace, 0o755); err != nil { + return nil, fmt.Errorf("mkdir workspace: %w", err) + } + + prompt := extractLastUserText(req.Messages) + if prompt == "" { + return nil, errors.New("gemini: no user text in request messages") + } + + resumeID := loadSessionID(workspace) + + args := []string{"--skip-trust", "--output-format", "json"} + if model != "" { + args = append(args, "--model", model) + } + if resumeID != "" { + args = append(args, "--resume", resumeID) + } + args = append(args, extraArgs...) + args = append(args, "-p", prompt) + + host.Log("info", "gemini: spawning CLI", map[string]any{ + "workspace": workspace, "model": model, "resume": resumeID, + "prompt_len": len(prompt), + }) + + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = workspace + cmd.Env = os.Environ() + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("gemini: start: %w", err) + } + + out := make(chan canonical.TurnEvent, 8) + go func() { + defer close(out) + err := cmd.Wait() + emit := func(ev canonical.TurnEvent) { + select { + case out <- ev: + case <-ctx.Done(): + } + } + emit(canonical.TurnEvent{Type: canonical.EventMessageStart}) + + // stderr is informational; log it but don't treat as fatal + // (gemini chatters about Ripgrep etc. on stderr). + if stderrBuf.Len() > 0 { + host.Log("debug", "gemini stderr", + map[string]any{"text": stderrBuf.String()}) + } + + // Parse the JSON output. gemini may prepend a "Ripgrep is not + // available." line BEFORE the JSON; skip leading non-{ bytes. + raw := stripPreamble(stdoutBuf.Bytes()) + var resp struct { + SessionID string `json:"session_id"` + Response string `json:"response"` + Stats struct { + Models map[string]struct { + Tokens struct { + Input int `json:"input"` + Total int `json:"total"` + Cached int `json:"cached"` + } `json:"tokens"` + } `json:"models"` + } `json:"stats"` + Error string `json:"error,omitempty"` + } + if perr := json.Unmarshal(raw, &resp); perr != nil { + // Subprocess may have died before producing JSON. + msg := stderrBuf.String() + if err != nil { + msg = fmt.Sprintf("%s (wait err: %v)", msg, err) + } + emit(canonical.TurnEvent{ + Type: canonical.EventError, + Error: &canonical.ErrorInfo{ + Code: "gemini_unparsable", + Message: fmt.Sprintf("gemini output not JSON: %v\nstdout=%q\nstderr=%q", + perr, truncate(string(raw), 200), truncate(msg, 400)), + }, + }) + emit(canonical.TurnEvent{ + Type: canonical.EventMessageEnd, StopReason: "error", + }) + return + } + if resp.SessionID != "" { + saveSessionID(workspace, resp.SessionID, host) + } + if resp.Error != "" { + emit(canonical.TurnEvent{ + Type: canonical.EventError, + Error: &canonical.ErrorInfo{Code: "gemini_error", Message: resp.Error}, + }) + } + if resp.Response != "" { + emit(canonical.TurnEvent{ + Type: canonical.EventTextDelta, Text: resp.Response, + }) + } + + var usage canonical.Usage + for _, m := range resp.Stats.Models { + usage.InputTokens += m.Tokens.Input + usage.OutputTokens += m.Tokens.Total - m.Tokens.Input + usage.CacheReadTokens += m.Tokens.Cached + } + + stopReason := canonical.StopEndTurn + if err != nil { + stopReason = "error" + } + emit(canonical.TurnEvent{ + Type: canonical.EventMessageEnd, + StopReason: stopReason, + Usage: &usage, + }) + }() + return out, nil +} + +// ---- helpers ---- + +func extractLastUserText(msgs []canonical.Message) string { + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Role != canonical.RoleUser { + continue + } + var parts []string + for _, b := range m.Content { + if t, ok := b.(*canonical.TextBlock); ok { + parts = append(parts, t.Text) + } + } + if len(parts) > 0 { + out := parts[0] + for _, p := range parts[1:] { + out += "\n" + p + } + return out + } + } + return "" +} + +func loadSessionID(workspace string) string { + raw, err := os.ReadFile(filepath.Join(workspace, SessionFile)) + if err != nil { + return "" + } + return string(bytes.TrimSpace(raw)) +} + +func saveSessionID(workspace, id string, host plugin.HostAPI) { + path := filepath.Join(workspace, SessionFile) + if err := os.WriteFile(path, []byte(id), 0o600); err != nil { + host.Log("warn", "gemini: save session id failed", + map[string]any{"err": err.Error(), "path": path}) + } +} + +// stripPreamble drops any non-`{` bytes before the first `{` so the +// "Ripgrep is not available" line gemini sometimes prepends doesn't +// trip json.Unmarshal. +func stripPreamble(b []byte) []byte { + i := bytes.IndexByte(b, '{') + if i <= 0 { + return b + } + return b[i:] +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..bc98f43 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Plexum-gemini-provider installer. +# +# Builds + installs the plugin under ~/.plexum/plugins/. +# Operator config (~/.plexum/plugins/plexum-gemini-provider/config.json) +# is optional — defaults work if `gemini` is on PATH and authenticated. +set -euo pipefail + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE_DIR="${HOME}/.plexum" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILE_DIR="$2"; shift 2 ;; + -h|--help) sed -n '2,/^set -euo/p' "$0" | sed -n '/^#/p' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +log() { printf '\033[1;34m[gemini-install]\033[0m %s\n' "$*"; } +command -v go >/dev/null || { echo "go not found on PATH" >&2; exit 1; } +command -v gemini >/dev/null || log "WARN: gemini CLI not on PATH yet (operator must install before first turn)" + +PLUGIN_DIR="${PROFILE_DIR}/plugins/plexum-gemini-provider" +mkdir -p "${PLUGIN_DIR}" + +cd "${REPO}" +VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" +LDFLAGS="-X main.Version=${VERSION}" +log "building plexum-gemini-provider-plugin (v=${VERSION})" +CGO_ENABLED=0 go build -ldflags="${LDFLAGS}" \ + -o "${PLUGIN_DIR}/plexum-gemini-provider-plugin" \ + ./cmd/plexum-gemini-provider-plugin + +cat > "${PLUGIN_DIR}/manifest.json" <<'EOF' +{ + "name": "plexum-gemini-provider", + "version": "0.1.0", + "activation": "lazy", + "executable": "plexum-gemini-provider-plugin", + "contracts": { + "provider": { + "models": ["gemini", "gemini-pro", "gemini-flash", "gemini-flash-lite"] + } + } +} +EOF + +cat < + 3. systemctl --user restart plexum +EOF