Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/pi/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package pi

import (
"context"
"fmt"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// GenerateText sends a prompt to Pi in non-interactive text mode and returns
// the raw response. The prompt is passed as a positional message because Pi's
// CLI consumes prompts from argv in --print mode.
func (a *PiAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) {
args := []string{"--print", "--no-tools", "--no-session"}
if model != "" {
args = append(args, "--model", model)
}
args = append(args, prompt)

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", args, "")
if err != nil {
return "", fmt.Errorf("pi text generation failed: %w", err)
}
return result, nil
}
17 changes: 17 additions & 0 deletions cmd/entire/cli/agent/pi/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.
Type: agent.TurnEnd,
SessionID: sessionID,
SessionRef: sessionRef,
Model: extractModelFromPiSessionFile(sessionRef),
Timestamp: now,
}, nil

Expand Down Expand Up @@ -293,6 +294,22 @@ func cacheSessionID(ctx context.Context, id string) {
}
}

func extractModelFromPiSessionFile(path string) string {
if path == "" {
return ""
}
//nolint:gosec // path comes from Pi's hook payload or our captured transcript path
data, err := os.ReadFile(path)
if err != nil {
return ""
}
model, err := (&PiAgent{}).ExtractModel(data)
if err != nil {
return ""
}
return model
}

func readCachedSessionID(ctx context.Context) string {
dir := resolveSessionDir(ctx)
//nolint:gosec // path constructed from validated repo root
Expand Down
49 changes: 49 additions & 0 deletions cmd/entire/cli/agent/pi/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package pi

import (
"bufio"
"context"
"fmt"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

var _ agent.ModelLister = (*PiAgent)(nil)

// ListModels returns Pi's live model catalog by shelling out to
// `pi --list-models`. Unlike the curated lists for claude-code/codex/gemini,
// Pi has a real enumeration command spanning every configured provider, so the
// result reflects what this machine/account can actually use.
func (a *PiAgent) ListModels(ctx context.Context) ([]agent.ModelInfo, error) {
out, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", []string{"--list-models"}, "")
if err != nil {
return nil, fmt.Errorf("pi --list-models: %w", err)
}
return parsePiModelList(out), nil
}

// parsePiModelList parses the tabular `pi --list-models` output. Each non-header
// row is "<provider> <model> <context> <max-out> <thinking> <images>"; the model
// ID is rendered as "provider/model" (the unambiguous form Pi's --model accepts)
// with the context window kept as a note.
func parsePiModelList(raw string) []agent.ModelInfo {
var models []agent.ModelInfo
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
provider, model := fields[0], fields[1]
if provider == "provider" && model == "model" {
continue // header row
}
note := ""
if len(fields) >= 3 {
note = fields[2] + " ctx"
}
models = append(models, agent.ModelInfo{ID: provider + "/" + model, Note: note})
}
return models
}
35 changes: 35 additions & 0 deletions cmd/entire/cli/agent/pi/models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package pi

import "testing"

func TestParsePiModelList(t *testing.T) {
raw := "provider model context max-out thinking images\n" +
"anthropic claude-opus-4-0 200K 32K yes yes \n" +
"openai gpt-5 400K 128K yes no \n" +
"\n" +
"google gemini-2.5-pro 1M 64K yes yes \n"

got := parsePiModelList(raw)
if len(got) != 3 {
t.Fatalf("parsed %d models, want 3: %#v", len(got), got)
}
want := []struct{ id, note string }{
{"anthropic/claude-opus-4-0", "200K ctx"},
{"openai/gpt-5", "400K ctx"},
{"google/gemini-2.5-pro", "1M ctx"},
}
for i, w := range want {
if got[i].ID != w.id {
t.Errorf("model[%d].ID = %q, want %q", i, got[i].ID, w.id)
}
if got[i].Note != w.note {
t.Errorf("model[%d].Note = %q, want %q", i, got[i].Note, w.note)
}
}
}

func TestParsePiModelList_HeaderAndBlanksSkipped(t *testing.T) {
if got := parsePiModelList("provider model\n\n \n"); len(got) != 0 {
t.Fatalf("expected no models, got %#v", got)
}
}
Loading
Loading