diff --git a/cmd/plexum-gemini-provider-plugin/main.go b/cmd/plexum-gemini-provider-plugin/main.go index 0d19050..6634c37 100644 --- a/cmd/plexum-gemini-provider-plugin/main.go +++ b/cmd/plexum-gemini-provider-plugin/main.go @@ -125,6 +125,35 @@ func (p *geminiPlugin) StreamWithAgent(ctx context.Context, modelID string, agen 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//chats/ +// session--.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 diff --git a/internal/runner/session_mutate.go b/internal/runner/session_mutate.go new file mode 100644 index 0000000..7971fbf --- /dev/null +++ b/internal/runner/session_mutate.go @@ -0,0 +1,91 @@ +// session_mutate.go — gemini session file path resolution. The actual +// JSONL rewrite is deferred (see plugin main.go MutateSession comment): +// gemini-cli uses its own tool-call id namespace that doesn't match +// Plexum's, so v1 ships as a logged no-op. This file owns the path +// resolver so the v2 rewriter has its entry point. +// +// Layout (observed): +// +// ~/.gemini/tmp//chats/session--.jsonl +// +// where is the first 8 chars of the session_id captured into +// workspace/.plexum-gemini-session. We pick the file whose name +// contains that prefix; multiple matches → the most-recent by mtime. + +package runner + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// FindGeminiSessionFile resolves the chat JSONL gemini-cli writes for +// the session id captured in workspace/.plexum-gemini-session. +// Returns ("", error) if no session id is recorded yet OR no chat +// file is on disk. +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 + } + root := filepath.Join(home, ".gemini", "tmp") + wsName := filepath.Base(workspace) + candidates := []string{ + filepath.Join(root, wsName, "chats"), + // Fallback dirs (older / alternate gemini-cli versions). We + // glob defensively so an operator's custom layout still + // surfaces something for telemetry. + } + entries := []os.DirEntry{} + chosenDir := "" + for _, dir := range candidates { + es, err := os.ReadDir(dir) + if err == nil { + entries = es + chosenDir = dir + break + } + } + if chosenDir == "" { + return "", fmt.Errorf("no gemini chats dir under %s", root) + } + // Pick the newest session-*.jsonl whose name contains the sid prefix. + 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: filepath.Join(chosenDir, e.Name()), + mtime: info.ModTime().UnixNano(), + }) + } + if len(matches) == 0 { + return "", fmt.Errorf("no chat file matching session_id prefix %q in %s", prefix, chosenDir) + } + sort.Slice(matches, func(i, j int) bool { return matches[i].mtime > matches[j].mtime }) + return matches[0].path, nil +}