262 lines
10 KiB
Go
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
|
|
}
|