// 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 - description (source: )", 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 }