Files
Plexum-gemini-provider/cmd/plexum-gemini-provider-plugin/main.go
hzhang dbe68a889c feat(gemini): SessionMutator stub + session-file resolver
Wires the host's mirror hook end-to-end: FindGeminiSessionFile resolves
~/.gemini/tmp/<workspace>/chats/session-<ISO>-<sid_prefix>.jsonl from
the session id captured into .plexum-gemini-session, picking the
most-recent match by mtime. Logs the no-op for telemetry.

Real rewrite is deferred to v2: gemini-cli's tool-call id namespace
doesn't match Plexum's canonical block format (and its on-disk shape
is `$set`-style mutation log rather than flat append-only). When
tool-call round-trip through gemini's native surface lands, the
rewriter plugs into this file's path resolver.
2026-06-01 14:01:33 +01:00

177 lines
6.2 KiB
Go

// 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)
}
// MutateSession is the session-mirror hook (decision #29x). gemini-cli
// stores transcripts under ~/.gemini/tmp/<workspace-name>/chats/
// session-<ISO>-<session_id_prefix>.jsonl with a `$set`-style mutation
// log (each line either a session-meta header or `{"$set":{...}}`
// applied in order). Tool-call records use gemini-cli's own id
// namespace (no shape doc as of this writing) which doesn't match
// Plexum's toolu_*-style block ids.
//
// v1 ships as a logged no-op: we resolve the file path so operator
// telemetry confirms wiring, but we don't attempt to rewrite. When
// Plexum starts routing tool-call ids through gemini's native surface
// (preserving the round-trip), the rewriter plugs in here.
func (p *geminiPlugin) MutateSession(ctx context.Context, req plugin.SessionMutateRequest) error {
path, err := runner.FindGeminiSessionFile(req.Workspace)
if err != nil {
p.host.Log("debug", "gemini session mutate: find failed", map[string]any{
"agent": req.AgentID, "err": err.Error(),
})
return nil
}
p.host.Log("info", "gemini session mutate (no-op v1)", map[string]any{
"agent": req.AgentID,
"session_path": path,
"requested": len(req.Mutations),
"reason": "gemini tool-call id round-trip not yet wired",
})
return nil
}
// 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)
}
}