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 <sid>] -p <prompt> in agent workspace
- Parses the single JSON blob gemini emits at exit
({"session_id":"...", "response":"...", "stats":{...}})
- session_id persisted at <workspace>/.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 <id> 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 <noreply@anthropic.com>
This commit is contained in:
91
README.md
Normal file
91
README.md
Normal file
@@ -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 <m>] [--resume <sid>] -p "<last user msg>"
|
||||||
|
```
|
||||||
|
|
||||||
|
with `cwd = <agent workspace>`. The CLI exits with a single JSON blob:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "uuid",
|
||||||
|
"response": "...",
|
||||||
|
"stats": {"models": {...}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We capture `session_id` into `<workspace>/.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 <id>` 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.
|
||||||
147
cmd/plexum-gemini-provider-plugin/main.go
Normal file
147
cmd/plexum-gemini-provider-plugin/main.go
Normal file
@@ -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>` (session id stashed at
|
||||||
|
// <workspace>/.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
|
||||||
|
// <profile>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -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
|
||||||
236
internal/runner/runner.go
Normal file
236
internal/runner/runner.go
Normal file
@@ -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 <m>] [--resume <sid>] -p <prompt>
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// <workspace>/.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] + "…"
|
||||||
|
}
|
||||||
67
scripts/install.sh
Executable file
67
scripts/install.sh
Executable file
@@ -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 <<EOF
|
||||||
|
|
||||||
|
[gemini-install] done.
|
||||||
|
plugin binary: ${PLUGIN_DIR}/plexum-gemini-provider-plugin
|
||||||
|
manifest: ${PLUGIN_DIR}/manifest.json
|
||||||
|
models: gemini / gemini-pro / gemini-flash / gemini-flash-lite
|
||||||
|
|
||||||
|
Optional config: ${PLUGIN_DIR}/config.json
|
||||||
|
{"binary": "gemini", "extra_args": []}
|
||||||
|
|
||||||
|
No api_key needed here — the gemini CLI handles auth via its own
|
||||||
|
~/.gemini/ state (OAuth or GEMINI_API_KEY).
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Allow plugin in ${PROFILE_DIR}/plexum.json:
|
||||||
|
.plugins.allow += ["plexum-gemini-provider"]
|
||||||
|
2. plexum agent-add --model gemini-flash <agent-id>
|
||||||
|
3. systemctl --user restart plexum
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user