Files
BlackBean/pigo/internal/cli/repl/interactive.go
T
2026-08-14 23:41:57 +08:00

262 lines
10 KiB
Go

// This file wires the line-based REPL (US-003) and session persistence
// (US-024, #43) into the pigo command. When invoked without a prompt on a
// terminal, pigo starts the REPL loop (see repl.go); each run's messages are
// persisted to a local JSONL session so the conversation can be listed, resumed
// and replayed later.
package repl
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/headless"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/dream"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
"github.com/smallnest/pigo/internal/trust"
)
// sessionStore returns the session store for the interactive REPL. It is a thin
// alias for headless.SessionStore so the REPL and headless runs share one store
// rooted at ~/.pigo/sessions (or PIGO_HOME).
func sessionStore() (*session.Store, error) {
return headless.SessionStore()
}
// Options carries the resolved run configuration plus optional
// resume state into Run.
type Options struct {
Model string
ProviderName string
Provider provider.Provider
BaseURL string
APIKey string
Protocol string
// ThinkingLevel is the resolved reasoning-effort level (US-023): it seeds the
// live run config so every REPL turn requests it, until a control command
// changes it.
ThinkingLevel agentcore.ThinkingLevel
Tools []agentcore.AgentTool
SysPrompt string
// ResumeID, when non-empty, resumes an existing session: its messages seed
// the context and replayed transcript. Otherwise a fresh session is created.
ResumeID string
// Approve, when true, grants the launch directory session trust before the
// run so the first-launch trust prompt is skipped and side-effect tools run
// without per-call confirmation (mirrors pi's --approve/-a).
Approve bool
// Skills is the pre-loaded skill set (loaded once by setupAgentEnv, shared
// with prompt injection). Each is registered as a /skill-name command. Empty
// under --no-skills, so nothing is registered.
Skills []*runtime.Skill
// Plugins holds the loaded plugin manager so the REPL can deliver lifecycle
// events to subscribed plugins (US-017, #133). It may be nil (no plugins).
Plugins *plugin.Manager
// ConfigPrompts holds prompt-template paths from the config.toml `prompts`
// array (settings tier); each is a file or dir loaded non-recursively.
ConfigPrompts []string
// CliPrompts holds --prompt-template paths (CLI tier, repeatable).
CliPrompts []string
// NoPromptTemplates disables all prompt-template discovery (global, project,
// settings, CLI); built-in slash commands are unaffected. Independent of
// --no-skills.
NoPromptTemplates bool
// Dream is the resolved [dream] configuration (US-008). Run uses it to decide
// whether to launch the startup background consolidation; a zero value
// (Enabled false) disables the auto-trigger entirely.
Dream dream.Config
}
// Run starts the line-based REPL over a persisted session. It keeps
// a single growing AgentContext across prompts (so turns share history) and
// saves the session's messages after each run completes (see runREPL/streamRun
// in repl.go).
func Run(opts Options) error {
creds := provider.NewCredentialStore(nil)
creds.SetOverride(opts.ProviderName, opts.APIKey)
reg := run.ToolRegistry(opts.Tools)
store, err := sessionStore()
if err != nil {
return err
}
// Resolve the launch directory once (pigo does not cd during a session). It is
// the trust key, the directory side-effect tools are gated against, and — new
// for #526 — the value stamped onto a fresh SessionHeader.Cwd so the session is
// attributed to a project and a later /dream pass can distill it under the
// right scope (mirrors headless.headlessCwd). An unresolvable cwd yields ""
// (the session stays unattributed) rather than aborting the session.
cwd, cwdErr := os.Getwd()
// Establish the session: resume an existing one or create a fresh header.
now := time.Now().UTC()
var (
agentCtx *agentcore.AgentContext
header session.SessionHeader
history []agentcore.AgentMessage
curLeaf string // active leaf id on resume; "" for a fresh session
)
if opts.ResumeID != "" {
// Interactive resume always appends a fresh user message before running,
// so a session that ended normally (trailing assistant reply) is resumable
// here. Load the raw session and rebuild the context directly.
h, entries, err := store.LoadEntries(opts.ResumeID)
if err != nil {
return err
}
msgs := make(agentcore.MessageList, len(entries))
for i, e := range entries {
msgs[i] = e.Message
}
if len(entries) > 0 {
curLeaf = entries[len(entries)-1].ID
}
header = h
agentCtx = &agentcore.AgentContext{SystemPrompt: h.SystemPrompt, Messages: msgs, Tools: opts.Tools}
history = msgs
if agentCtx.SystemPrompt == "" {
agentCtx.SystemPrompt = opts.SysPrompt
}
} else {
agentCtx = &agentcore.AgentContext{SystemPrompt: opts.SysPrompt, Tools: opts.Tools}
header = session.SessionHeader{
ID: session.NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: opts.Model,
Provider: opts.ProviderName,
SystemPrompt: opts.SysPrompt,
Cwd: cwd,
}
}
// live holds the run configuration that a control command (e.g. /model) may
// mutate mid-session. streamRun reads it on each prompt so a model switch
// takes effect on the next turn; header is updated so the switch is persisted
// with the session.
live := &cli.LiveConfig{
Model: opts.Model,
ProviderName: opts.ProviderName,
Provider: opts.Provider,
BaseURL: opts.BaseURL,
Protocol: opts.Protocol,
ThinkingLevel: opts.ThinkingLevel,
ContextWindow: cli.DefaultContextWindow,
}
// Project trust (US-018, #134): load the persisted trust store for the
// launch directory. A load failure (e.g. a corrupted trust.json) is
// non-fatal: trust is disabled (mgr stays nil) and the REPL still runs -
// the store is surfaced rather than silently overwritten. cwd is captured
// once above since pigo does not cd during a session; if it cannot be resolved
// trust is disabled too, since an empty cwd would silently never match.
mgr, mgrErr := trust.NewManager(trust.DefaultPath())
if mgrErr != nil {
fmt.Fprintf(os.Stderr, "pigo: trust store unavailable, trust disabled: %v\n", mgrErr)
mgr = nil
}
if cwdErr != nil && mgr != nil {
fmt.Fprintf(os.Stderr, "pigo: cannot resolve working directory, trust disabled: %v\n", cwdErr)
mgr = nil
}
// in is the shared input reader for the main loop and the tool-call
// confirmation prompt (see repl.go). Wrapping os.Stdin once here means both
// read from the same buffer.
reader := bufio.NewReaderSize(os.Stdin, replScanBufInit)
// Wire slash-commands: built-ins (compile-time) plus any user templates under
// ~/.pigo/commands (mirrors the commands/*.md convention) plus skills under
// ~/.agents/skills. A load error is non-fatal — the REPL still runs with the
// built-ins. Instance built-ins that need live state (/model, /help) are
// registered against `live`.
slash, err := prompts.BuildSlashRegistry(live, opts.Skills, opts.Plugins, prompts.PromptTemplateSources{
Settings: opts.ConfigPrompts,
CLI: opts.CliPrompts,
Disable: opts.NoPromptTemplates,
ProjectDir: filepath.Join(cwd, ".pigo", "prompts"),
ProjectTrusted: mgr != nil && mgr.IsTrusted(cwd),
})
if err != nil {
fmt.Fprintf(os.Stderr, "pigo: slash-commands: %v\n", err)
}
trust.RegisterCommand(slash, mgr, cwd)
// --approve grants the launch directory session trust up front (mirrors pi's
// --approve/-a), so the first-launch prompt is skipped and side-effect tools
// run without per-call confirmation. Otherwise, on the first launch in an
// undecided directory, ask the user how much to trust it before any tool
// runs. This happens before replay so the trust question is the first thing
// the user sees, not their prior history.
trust.EstablishTrust(os.Stdout, reader, mgr, cwd, opts.Approve)
// Replay the resumed conversation so the user sees history before re-prompting.
if len(history) > 0 {
replayTranscript(os.Stdout, history)
}
// Startup background consolidation (US-008, FR-4/FR-17): if dream is enabled
// and due, spawn `pigo --dream` in a goroutine now so it runs while the user
// works — it never blocks the first prompt, and prints a one-line notice on
// completion. The dream state/lock live under dream.ResolveMemoryRoot (the
// same root the subprocess consolidates), independent of whether the memory
// tool is wired into this session. Not-due / disabled is a cheap no-op.
maybeStartBackgroundDream(os.Stdout, dream.ResolveMemoryRoot(), cwd, opts.Dream)
return runREPL(os.Stdin, os.Stdout, replDeps{
store: store,
header: header,
agentCtx: agentCtx,
live: live,
reg: reg,
reminders: run.TodoReminders(opts.Tools),
slash: slash,
creds: creds,
trust: mgr,
cwd: cwd,
in: reader,
confirmMu: &sync.Mutex{},
curLeaf: curLeaf,
persisted: len(history),
memoryRoot: run.MemoryRootFromTools(opts.Tools),
memstore: run.MemoryStoreFromTools(opts.Tools),
snap: run.SnapshotRecorderFromTools(opts.Tools),
jobs: run.BashJobStoreFromTools(opts.Tools),
notifier: plugin.NewEventNotifier(opts.Plugins, os.Stderr),
goal: agenttool.NewGoalState(),
telemetry: cli.NewTelemetryHolder(),
})
}
// formatHelpLine renders one slash-command line for /help as
// "/name <argument-hint> - description (source: <tier>)", omitting the hint
// segment when absent. It is the plain, testable form of the /help line; the
// /help Action applies color on top of the same structure.
func formatHelpLine(c runtime.SlashCommand) string {
s := "/" + c.Name
if c.ArgumentHint != "" {
s += " " + c.ArgumentHint
}
if c.Description != "" {
s += " - " + c.Description
}
s += " (source: " + c.Tier.String() + ")"
return s
}