feat(gemini): plexum-host MCP exposure + real consume mirror

Same architecture as the codex sister change:

1. mcp-host subcommand routes argv[1] to mcpbridge.Run, reading
   PLEXUM_MCP_SOCKET / PLEXUM_MCP_AGENT_ID from gemini-cli's
   `gemini mcp add --env` env baking.

2. EnsureGeminiMCPRegistered handles per-agent stable registration
   under `-s user --trust` so gemini auto-approves the tool surface.

3. Post-turn ParseGeminiToolCalls + EmitGeminiToolCalls scans the
   chat JSONL at ~/.gemini/tmp/<ws>/chats/session-*.jsonl. Pairs
   gemini's nested `toolCalls[].id` with the matching
   `functionResponse.id` from later user lines.

4. MutateGeminiSession rewrites the chat JSONL: toolCalls[].args
   → {} (heavy), functionResponse.response.output → marker.

E2E verified: gemini call exec via plexum-host → dynamic.jsonl
records the call → dynamic-tool-clear consumes → gemini session
mirrored → resume gemini sees consumed marker not the original.
This commit is contained in:
h z
2026-06-01 20:14:13 +01:00
parent dbe68a889c
commit a63f477463
6 changed files with 558 additions and 113 deletions

View File

@@ -0,0 +1,237 @@
// session_mutate_apply — gemini consume mirror. Rewrites the chat
// JSONL so the consumed view propagates to next-resume gemini.
//
// Per-line mutation:
//
// type="gemini" line: every toolCalls[i].id matching → if heavy,
// reset args to {}
// type="user" line: every content[j].functionResponse.id matching
// → set response.output to the marker
package runner
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
const (
consumedMarker = "...(consumed)"
toolCalledMarker = "...(tool called)"
)
// GeminiMutation mirrors plugin.BlockMutation.
type GeminiMutation struct {
BlockID string
Op string
}
// MutateGeminiSession edits gemini's chat JSONL in lockstep with
// Plexum's dynamic.jsonl. Returns the count of distinct ids touched.
func MutateGeminiSession(workspace string, mutations []GeminiMutation) (int, error) {
if len(mutations) == 0 {
return 0, nil
}
path, err := FindGeminiSessionFile(workspace)
if err != nil {
return 0, nil // no session yet
}
byID := make(map[string]string, len(mutations))
for _, m := range mutations {
byID[m.BlockID] = m.Op
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
return 0, fmt.Errorf("open gemini session: %w", err)
}
defer f.Close()
var out strings.Builder
touched := map[string]struct{}{}
mutated := false
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
out.WriteByte('\n')
continue
}
rewritten, hits := rewriteGeminiLine(line, byID)
out.Write(rewritten)
out.WriteByte('\n')
for _, h := range hits {
touched[h] = struct{}{}
mutated = true
}
}
if err := sc.Err(); err != nil {
return 0, fmt.Errorf("scan gemini session: %w", err)
}
if !mutated {
return 0, nil
}
if err := writeAtomic(path, []byte(out.String())); err != nil {
return len(touched), fmt.Errorf("rewrite gemini session: %w", err)
}
return len(touched), nil
}
// rewriteGeminiLine returns rewritten bytes + list of touched ids.
func rewriteGeminiLine(line []byte, byID map[string]string) ([]byte, []string) {
var obj map[string]any
if err := json.Unmarshal(line, &obj); err != nil {
return line, nil
}
var touched []string
mutated := false
switch obj["type"] {
case "gemini":
tcs, ok := obj["toolCalls"].([]any)
if !ok {
return line, nil
}
for i, tcAny := range tcs {
tc, ok := tcAny.(map[string]any)
if !ok {
continue
}
id, _ := tc["id"].(string)
op, ok := byID[id]
if !ok || id == "" {
continue
}
if op == "consume-heavy" {
tc["args"] = map[string]any{}
tcs[i] = tc
touched = append(touched, id)
mutated = true
}
}
obj["toolCalls"] = tcs
case "user":
contents, ok := obj["content"].([]any)
if !ok {
return line, nil
}
for i, ctAny := range contents {
ct, ok := ctAny.(map[string]any)
if !ok {
continue
}
fr, ok := ct["functionResponse"].(map[string]any)
if !ok {
continue
}
id, _ := fr["id"].(string)
op, ok := byID[id]
if !ok || id == "" {
continue
}
marker := consumedMarker
if op == "consume-heavy" {
marker = toolCalledMarker
}
resp, _ := fr["response"].(map[string]any)
if resp == nil {
resp = map[string]any{}
}
resp["output"] = marker
fr["response"] = resp
ct["functionResponse"] = fr
contents[i] = ct
touched = append(touched, id)
mutated = true
}
obj["content"] = contents
default:
return line, nil
}
if !mutated {
return line, nil
}
rewritten, err := json.Marshal(obj)
if err != nil {
return line, nil
}
return rewritten, touched
}
func writeAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// FindGeminiSessionFile resolves ~/.gemini/tmp/<ws>/chats/session-*-<sid_prefix>.jsonl
// for the captured session id. Picks the most-recent match by mtime
// since gemini may roll the chat across multiple files within the
// same logical session id.
func FindGeminiSessionFile(workspace string) (string, error) {
sid := loadSessionID(workspace)
if sid == "" {
return "", errors.New("no gemini session id captured yet")
}
prefix := sid
if len(prefix) > 8 {
prefix = prefix[:8]
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
wsName := filepathBase(workspace)
dir := home + "/.gemini/tmp/" + wsName + "/chats"
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("read gemini chats dir %s: %w", dir, err)
}
type cand struct {
path string
mtime int64
}
var matches []cand
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
if !strings.Contains(e.Name(), prefix) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
matches = append(matches, cand{path: dir + "/" + e.Name(), mtime: info.ModTime().UnixNano()})
}
if len(matches) == 0 {
return "", fmt.Errorf("no chat file matching session_id prefix %q in %s", prefix, dir)
}
// pick newest
best := matches[0]
for _, m := range matches[1:] {
if m.mtime > best.mtime {
best = m
}
}
return best.path, nil
}
func filepathBase(p string) string {
// minimal local impl to keep imports tight
i := strings.LastIndex(p, "/")
if i < 0 {
return p
}
return p[i+1:]
}