// This file implements the line-based interactive REPL (US-003/#106) that // replaces the former full-screen bubbletea TUI. When pigo is invoked without a // prompt on a terminal it runs a simple read → run → stream-print loop over a // persisted session: no full-screen rendering, no popup menus, no viewport — a // prompt, the agent's streamed reply, and a new prompt. // // The REPL is a synchronous loop on the main goroutine: it reads one line, // resolves slash-commands, launches an agent run, drains the run's event stream // to stdout as it streams, persists the session, and returns to the prompt. A // SIGINT during a run cancels that run's context and returns to the prompt; when // idle, the reader sees EOF/interrupt and exits cleanly. package repl import ( "bufio" "context" "errors" "fmt" "io" "os" "os/signal" "strconv" "strings" "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/btw" "github.com/smallnest/pigo/internal/cli/goal" "github.com/smallnest/pigo/internal/cli/memstatus" "github.com/smallnest/pigo/internal/cli/run" "github.com/smallnest/pigo/internal/cli/status" "github.com/smallnest/pigo/internal/cli/ui" "github.com/smallnest/pigo/internal/clipboard" "github.com/smallnest/pigo/internal/compaction" "github.com/smallnest/pigo/internal/hooks" "github.com/smallnest/pigo/internal/memory" "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" ) // replDeps bundles the collaborators a REPL run needs. They are assembled once // by Run and reused across every prompt in the session. type replDeps struct { store *session.Store header session.SessionHeader agentCtx *agentcore.AgentContext live *cli.LiveConfig reg *agenttool.ToolRegistry // reminders holds the per-turn system-reminder providers (US-002). It is nil // when no todo tool is present; when set, streamRun wires it so ephemeral // context is injected into each turn's request. reminders *runtime.ReminderRegistry slash *runtime.SlashRegistry creds *provider.CredentialStore // notifier delivers agent lifecycle events to subscribed plugins (US-017, // #133). It is nil when no plugin subscribes; DrainStream's OnEvent stays // unset in that case. notifier *plugin.EventNotifier // trust persists project-trust decisions (US-018, #134). It is nil when // trust is disabled (e.g. the store could not be loaded); when nil the // BeforeToolCall hook is not installed and the first-run prompt is skipped. trust *trust.Manager // cwd is the directory pigo was launched in, used as the trust key and as // the directory side-effect tools are gated against. It does not change // during a session (pigo does not cd). cwd string // in is the shared buffered input reader. The main loop and the tool-call // confirmation prompt both read from it so input typed ahead is never split // between them. It is created by Run (wrapping os.Stdin) or, for // direct test callers, lazily from the in argument at the top of runREPL. in *bufio.Reader // confirmMu serializes tool-call confirmation prompts so concurrent // parallel side-effect tool calls do not interleave on the shared // stdin/stdout. It is a pointer so every value copy of replDeps (passed to // streamRun per prompt) shares one mutex. confirmMu *sync.Mutex // curLeaf is the id of the entry the conversation currently descends from — // the active leaf of the on-disk session tree (US-007, #123). A fresh session // starts empty (""); each persisted turn advances it to the newly written // leaf. /tree can move it to a historical entry so the next turn branches from // there rather than the tip. curLeaf string // persisted is the number of agentCtx.Messages already written to disk. The // per-turn persist appends only Messages[persisted:] as a branch descending // from curLeaf, so switching leaves and continuing grows a real tree instead // of rewriting the file as a single linear chain. persisted int // goal holds the session's autonomous-goal state (mirrors pi-goal), driven by // the /goal command. It is always non-nil (Run seeds an idle // state); the GoalReminderProvider and the goal tools share this handle so // the objective is injected each turn and goal_complete/goal_blocked can end // the run. goal *agenttool.GoalState // lastBtw holds the most recent /btw side thread from this process (US-004, // #281), so a bare "/btw" reopens it and shows its history. It is nil until // the first /btw and is reset to nil on any session switch (/fork, /clone, // /import) because a side thread branched from the old conversation no longer // makes sense against a different one. It is never persisted: restarting pigo // starts with lastBtw nil again. lastBtw *agentcore.AgentContext // lastBtwBase is the number of leading messages in lastBtw.Messages that were // copied from the main conversation as background — the side thread's own Q&A // is everything from this index on. Reopening replays only Messages[base:] so // the whole main transcript is not reprinted. lastBtwBase int // telemetry holds the retained per-run telemetry events (US-001, #291) and // the cumulative accumulator that sums metrics across all runs in the session. // It is reset to "no telemetry yet" on any session switch (/fork, /clone, // /import). telemetry *cli.TelemetryHolder // dispatcher is the run's hook dispatcher (#425), nil when no hooks are // configured (FR-18) so every hook path is skipped. It is resolved once per // session (trust-gated, FR-14) at the top of runREPL, which also fires // SessionStart. Each turn's streamRun installs the per-turn seams // (PreToolUse/PostToolUse/Stop) and runs UserPromptSubmit through it. dispatcher *hooks.Dispatcher // hookDeps carries the session id / project dir stamped onto every HookInput. hookDeps run.HookDeps // remote holds the active remote-control session (#443), or nil when remote // control is off. The /remote-control command sets/clears it in place; the // input loop selects on its RemoteInput channel and the confirm seam routes // tool-call approvals to the paired browser while it is non-nil. When nil the // REPL behaves exactly as before (byte-identical). remote *remoteSession // tee wraps out so REPL output can be mirrored to the remote browser while a // session is active. It is created at the top of runREPL; the /remote-control // command toggles its secondary writer. When no remote session is active it // forwards only to the terminal. tee *teeWriter // memoryRoot is the persistent-memory Store root (empty when memory is // disabled). streamRun routes auto-compaction checkpoints here and /rebuild // recovers from /sessions//, the canonical checkpoint location. memoryRoot string // memstore is the live persistent-memory Store (nil when memory is disabled). // It lets /memory inspect entry counts without re-opening the database. memstore *memory.Store // snap is the shared file-snapshot recorder backing /rewind (nil under // --no-tools). Each turn's write/edit mutations are captured into it; after the // turn persists, commitRewindPoint groups them into a restore point tagged with // the pre-turn leaf so /rewind can roll files and the conversation back together. snap *agenttool.FileSnapshotRecorder // jobs holds background bash jobs launched with run_in_background. On REPL // exit its still-running jobs are killed so background processes are not // orphaned. nil when the shell tool is disabled. jobs *agenttool.BashJobStore } // replScanBufInit is the initial size of the shared input reader. A REPL user // may paste a long single line (a big prompt or a pasted file); bufio.Reader // grows its returned string beyond this buffer on demand, so a long line is // still read whole (just accumulated rather than capped). This drops the hard // 4MiB cap the previous bufio.Scanner had: ReadString is unbounded, so a // pathological paste can grow the line buffer. That is an acceptable tradeoff // for a terminal REPL (where OS line buffering bounds normal input) in exchange // for correct buffer sharing with the tool-call confirmation prompt. const replScanBufInit = 64 * 1024 // notifierHandle returns the plugin event-delivery callback for this session's // runs, or nil when no plugin subscribed. Returning nil (rather than a func that // dispatches to a nil notifier) keeps DrainStream's OnEvent unset in the common // no-plugin case, so the drain loop skips the per-event call entirely. func (deps replDeps) notifierHandle() func(agentcore.AgentEvent) { if deps.notifier == nil { return nil } return deps.notifier.Handle } // runREPL runs the read → run → stream-print loop until EOF, /exit or /quit. It // reads from in (os.Stdin in production) and writes prompts, streamed replies // and status lines to out (os.Stdout). It is the interactive replacement for the // bubbletea program. func runREPL(in io.Reader, out io.Writer, deps replDeps) error { // Use the shared buffered reader so the main loop and the tool-call // confirmation prompt read from the same buffer: input is never split // between them the way it would be if the loop used a bufio.Scanner with // its own private buffer (a Scanner can read past the current line into its // internal buffer, trapping bytes the confirmation prompt would then never // see). deps.in is set by Run (wrapping os.Stdin); the in // parameter is only a fallback for direct callers (tests) that do not set // deps.in, and is ignored once deps.in is populated. if deps.in == nil { deps.in = bufio.NewReaderSize(in, replScanBufInit) } // Mirror all REPL output to the remote browser while a /remote-control // session is active (#443). teeWriter forwards to the terminal // unconditionally and, when the command toggles a secondary, also to the // bridge's output writer. When remote control is off it is a thin // pass-through, so terminal output is byte-identical to before. deps.tee = newTeeWriter(out) out = deps.tee // Stop a still-running remote-control server when the REPL exits (FR-16), so // quitting pigo tears down the LAN listener rather than leaking it. The // closure reads deps.remote at exit time, so it covers a session started mid // run; an explicit /remote-control stop clears deps.remote and makes this a // no-op. defer func() { if deps.remote != nil { _ = deps.remote.server.Stop(context.Background()) deps.remote = nil } }() // Kill any still-running background bash jobs on exit so long-running // commands (dev servers, watchers) launched with run_in_background are not // orphaned when the REPL quits. No-op when the shell tool is disabled. defer func() { if deps.jobs != nil { deps.jobs.KillAll() } }() // Resolve the run's hooks once per session and fire SessionStart so any // injected context lands in the first turn (#423/#425). The project layer is // honored only when the working directory is trusted (FR-14). A malformed hook // layer disables hooks with a warning rather than aborting an interactive // session. deps.dispatcher stays nil when no hooks are configured (FR-18), so // streamRun skips all hook work. deps.hookDeps = run.HookDeps{SessionID: deps.header.ID, ProjectDir: deps.cwd, WarnLog: out} trusted := deps.trust != nil && deps.trust.IsTrusted(deps.cwd) if set, err := run.ResolveHookSet(deps.cwd, trusted); err != nil { fmt.Fprintf(out, "pigo: hooks disabled: %v\n", err) } else if d := run.BuildDispatcher(set, deps.hookDeps); d != nil { deps.dispatcher = d if deps.reminders == nil { deps.reminders = runtime.NewReminderRegistry() } ssCfg := runtime.RunConfig{Reminders: deps.reminders} run.DispatchSessionStart(context.Background(), d, &ssCfg, deps.hookDeps, "startup") deps.reminders = ssCfg.Reminders } var priorInputs []string for _, msg := range deps.agentCtx.Messages { if user, ok := msg.(agentcore.UserMessage); ok { priorInputs = append(priorInputs, agentcore.ContentToText(user.Content)) } } editor := newREPLLineEditor(in, deps.in, out, deps.slash, priorInputs) editor.models = append([]string{deps.live.Model}, editor.models...) // A SIGINT during a run cancels only that run; the handler is installed for // the whole REPL and targets whichever run is active via runCancel. runCancel // is read on the signal goroutine and written on the main loop, so a mutex // guards it against a data race. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) defer signal.Stop(sigCh) var ( mu sync.Mutex runCancel context.CancelFunc ) setCancel := func(c context.CancelFunc) { mu.Lock() runCancel = c mu.Unlock() } go func() { for range sigCh { mu.Lock() c := runCancel mu.Unlock() if c != nil { c() } } }() // Input is acquired by a dedicated reader goroutine so the main loop can // select between a locally-typed line and a line submitted from the paired // browser (#443). The goroutine reads one line per request sent on promptReq // and returns it on localCh; the loop only issues a new request when the // reader is idle (readerBusy == false), so a browser-submitted line that // arrives while a local ReadLine is still blocked does not deadlock or // double-prompt. When remote control is off, remoteCh is nil (blocks // forever), so the select degenerates to a plain local read — behavior is // identical to before. type lineResult struct { raw string err error } promptReq := make(chan string) localCh := make(chan lineResult, 1) go func() { for p := range promptReq { raw, err := editor.ReadLine(p) localCh <- lineResult{raw: raw, err: err} } }() defer close(promptReq) readerBusy := false for { replPrompt := fmt.Sprintf("pigo(%s)> ", deps.live.Model) if !readerBusy { fmt.Fprintln(out) promptReq <- replPrompt readerBusy = true } var ( raw string err error fromRemote bool ) select { case res := <-localCh: readerBusy = false raw, err = res.raw, res.err case rl := <-deps.remote.inputChan(): // A browser-submitted line. The pending local ReadLine stays blocked // (readerBusy remains true) and will be consumed on a later iteration. raw, fromRemote = rl, true // Echo the remote line locally so the terminal (and, via the tee, the // browser) shows what was submitted, mirroring a typed prompt. fmt.Fprintf(out, "%s%s\n", replPrompt, raw) } if errors.Is(err, errLineInterrupted) { continue } if err != nil && raw == "" { // EOF (Ctrl+D) or read error with no partial line: exit cleanly. fmt.Fprintln(out) if err == io.EOF { return nil } return err } line := strings.TrimSpace(raw) if !fromRemote { editor.remember(line) } if line == "" { if err != nil { // A trailing partial line at EOF that trims to empty: exit. fmt.Fprintln(out) return nil } continue } if line == "/exit" || line == "/quit" { return nil } if line == "/compact" { // /compact is intercepted here (like /exit) because compaction must run // an agent stream and mutate the shared context — neither of which a // slash Action closure (string in, string out) can do. Compaction // replaces the whole message list with a summary + tail, so the session // is rewritten linearly (Save) and the branch-tracking state is reset to // the new flattened leaf. runManualCompact(out, deps) deps.header.UpdatedAt = time.Now().UTC() if err := deps.store.Save(deps.header, deps.agentCtx.Messages); err != nil { fmt.Fprintf(out, "pigo: session save failed: %v\n", err) } deps.persisted = len(deps.agentCtx.Messages) deps.curLeaf = "" if _, entries, err := deps.store.LoadEntries(deps.header.ID); err == nil && len(entries) > 0 { deps.curLeaf = entries[len(entries)-1].ID } continue } if line == "/rebuild" { // /rebuild is intercepted here for the same reason as /compact: it // reconstructs the whole message list (checkpoint summary + retained // tail) and mutates the shared context, which a slash Action closure // cannot do. It reloads a persisted checkpoint when present, else falls // back to lossy compaction. Like /compact, the session is rewritten // linearly (Save) and the branch cursor reset to the new flattened leaf. runManualRebuild(out, deps) deps.header.UpdatedAt = time.Now().UTC() if err := deps.store.Save(deps.header, deps.agentCtx.Messages); err != nil { fmt.Fprintf(out, "pigo: session save failed: %v\n", err) } deps.persisted = len(deps.agentCtx.Messages) deps.curLeaf = "" if _, entries, err := deps.store.LoadEntries(deps.header.ID); err == nil && len(entries) > 0 { deps.curLeaf = entries[len(entries)-1].ID } continue } if line == "/clone" || line == "/fork" || strings.HasPrefix(line, "/fork ") { // /fork and /clone are intercepted here (like /compact) because they // switch the active session — replacing the header and the shared // context in place — which a slash Action closure (pure string→string) // cannot do. runForkClone saves the current session, forks it, and // swaps deps.header / deps.agentCtx to the new branch on success. runForkClone(out, &deps, line) continue } if line == "/tree" || strings.HasPrefix(line, "/tree ") { // /tree is intercepted here (like /fork) because "/tree " moves the // active leaf and rebuilds the shared context in place — mutating // per-run state a slash Action closure cannot reach. With no argument // it just prints the tree. runTree(out, &deps, line) continue } if line == "/rewind" || strings.HasPrefix(line, "/rewind ") { // /rewind is intercepted here (like /tree) because "/rewind " restores // files on disk AND moves the active leaf, rebuilding the shared context in // place — per-run state a slash Action closure cannot reach. With no // argument it lists the restore points. runRewind(out, &deps, line) continue } if line == "/export" || strings.HasPrefix(line, "/export ") { // /export writes the current session to a file. It is intercepted here // (not a slash Action) because it must first persist the live turn so the // export reflects unsaved messages. The exact-or-space-prefix guard keeps // "/exporter" from being mistaken for "/export". runExport(out, &deps, line) continue } if line == "/import" || strings.HasPrefix(line, "/import ") { // /import loads a JSONL export as a fresh session and switches to it, // swapping deps.header / deps.agentCtx in place — which a slash Action // closure cannot do. The guard keeps "/important" from matching. runImport(out, &deps, line) continue } if line == "/copy" { // /copy writes the most recent assistant text to the clipboard. It is // intercepted here (not a slash Action) because it must read the live // message list, which an Action closure cannot reach. runCopy(out, &deps) continue } if line == "/session" { // /session prints live session stats (message count, tokens, compactions) // derived from deps.header + the in-memory context — state a pure // string→string Action closure cannot see. runSession(out, &deps) continue } if line == "/status" || strings.HasPrefix(line, "/status ") { // /status prints a colored multi-section status report with runtime // config, context usage, and more — state a pure string→string Action // closure cannot see. The exact-or-space-prefix guard keeps "/statusfoo" // from being mistaken for "/status". status.RunStatus(out, &deps) continue } if line == "/memory" || strings.HasPrefix(line, "/memory ") { // /memory prints the persistent-memory + infinite-context report. // Like /status it needs live state (the memory store, memory root, // session id, and messages) that a string→string Action closure // cannot reach, so it is intercepted here. memstatus.RunMemory(out, deps.memstore, deps.memoryRoot, deps.header.ID, deps.agentCtx.Messages, deps.live.ContextWindow) continue } if line == "/dream" || strings.HasPrefix(line, "/dream ") { // /dream is intercepted here (like /memory/status) because it spawns the // process-isolated consolidation subprocess (pigo --dream) and renders the // returned Report against live state (deps.cwd / deps.memoryRoot) — work a // string→string Action closure cannot do. It never mutates the shared // context, so no session re-save/leaf reset is needed. The // exact-or-space-prefix guard keeps "/dreamer" from matching, and // "/dream --dry-run" runs the same analysis without writing. runDream(out, deps, line) continue } if line == "/goal" || strings.HasPrefix(line, "/goal ") { // /goal is intercepted here (like /compact) because it must run one or // more agent streams and mutate the shared context/goal state — none of // which a slash Action closure (string→string) can do. It drives the // autonomous goal loop, reusing the same SIGINT cancel plumbing as a // normal turn via setCancel. goal.RunGoal(setCancel, out, &deps, line) continue } if line == "/btw" || strings.HasPrefix(line, "/btw ") { // /btw is intercepted here (like /goal) because it must run an agent // stream against a COPY of the main context and must NOT mutate or // persist the main conversation — none of which a slash Action closure // can express. The exact-or-space-prefix guard keeps "/btweak" from // being mistaken for "/btw". It reuses the same SIGINT cancel plumbing // as a normal turn via setCancel. btw.RunBtw(setCancel, out, &deps, editor, line) continue } if line == "/remote-control" || strings.HasPrefix(line, "/remote-control ") { // /remote-control is intercepted here (not a slash Action) because it // starts/stops the in-process server and toggles deps.remote / the // output tee in place — per-session state a pure string→string Action // closure cannot reach (#443). The exact-or-space-prefix guard keeps a // longer command from being mistaken for it. runRemoteControl(out, &deps, line) continue } // Resolve slash-commands: an action command runs and prints its message // (no agent run); a prompt command or skill expands to the text we run; a // hybrid (plugin) command runs its side effect, prints its message // (notifications), then runs the returned prompt if non-empty; an unknown // command prints an error and returns to the prompt. prompt := line if strings.HasPrefix(line, "/") { outcome, err := deps.slash.ResolveOutcome(line) if err != nil { fmt.Fprintf(out, "%v\n", err) continue } if outcome.Kind == runtime.SlashAction { if outcome.Message != "" { fmt.Fprintln(out, outcome.Message) } continue } // SlashPrompt. A hybrid command may carry a Message to surface first // (e.g. plugin notifications) alongside the prompt to run. if outcome.Message != "" { fmt.Fprintln(out, outcome.Message) } // A hybrid command with no prompt (only notifications) has nothing to // run; return to the prompt without starting a turn. if outcome.Prompt == "" { continue } prompt = outcome.Prompt } // Launch the run and stream it to out. runCancel is published so the SIGINT // handler can interrupt this run; it is cleared when the run settles. runCtx, cancel := context.WithCancel(context.Background()) setCancel(cancel) // Capture the leaf the turn descends from before it advances, so a rewind // to this turn can move the conversation back to exactly here. preTurnLeaf := deps.curLeaf streamRun(runCtx, out, deps, prompt) cancel() setCancel(nil) // Persist the turn as a branch descending from the active leaf, so a // prior /tree leaf-switch produces a real sibling branch on disk rather // than a truncated linear rewrite. deps.header.Model = deps.live.Model deps.header.Provider = deps.live.ProviderName cli.PersistTurn(out, &deps) // Group this turn's file mutations (if any) into a rewind restore point. deps.snap.Commit(preTurnLeaf, rewindLabel(prompt)) } } // streamRun appends the prompt to the shared context, starts an agent run, and // prints the streamed assistant text and tool activity to out. It blocks until // the run ends. The context grows in place so the next prompt continues the // conversation. func streamRun(ctx context.Context, out io.Writer, deps replDeps, prompt string) { content, err := ui.BuildUserContent(prompt) if err != nil { fmt.Fprintf(out, "pigo: %v\n", err) return } // UserPromptSubmit runs before the prompt is committed to the shared context, // so a block aborts the turn without leaving a dangling user message; any // additionalContext is injected into this turn only (one-shot reminder). if deps.dispatcher != nil { pc := runtime.RunConfig{Reminders: deps.reminders} if block, reason := run.DispatchUserPromptSubmit(ctx, deps.dispatcher, &pc, deps.hookDeps, prompt); block { fmt.Fprintf(out, "pigo: prompt blocked by hook: %s\n", reason) return } deps.reminders = pc.Reminders } deps.agentCtx.Messages = append(deps.agentCtx.Messages, agentcore.UserMessage{ RoleField: agentcore.RoleUser, Content: content, }) cfg := runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: deps.live.Model, Provider: deps.live.ProviderName, ThinkingLevel: deps.live.ThinkingLevel, Stream: provider.StreamFnFromProvider(deps.live.Provider), GetAPIKey: deps.creds.GetAPIKey, ContextWindow: deps.live.ContextWindow, Compaction: compaction.DefaultCompactionSettings, }, Batch: agenttool.BatchConfig{ ToolExecutorConfig: agenttool.ToolExecutorConfig{ Registry: deps.reg, BeforeToolCall: beforeToolCall(deps, out), }, }, Reminders: deps.reminders, SessionID: deps.header.ID, MemoryRoot: deps.memoryRoot, } // Per-turn wiring of the tool-execution + Stop seams (PreToolUse/PostToolUse/ // Stop) onto this turn's freshly-built cfg; a nil dispatcher is a no-op so the // hot path pays nothing when no hooks are configured (FR-18). if deps.dispatcher != nil { run.InstallSeams(&cfg, deps.dispatcher, deps.hookDeps) } stream := runtime.StartRun(ctx, deps.agentCtx, cfg) // The assistant reply is Markdown, which can only be laid out once the whole // block is known, so streamed text is buffered here and rendered at turn end // (ui.RenderMarkdown). On non-terminal output it returns the raw // source, so pipes/tests are unchanged. flushReply guarantees the rendered // block ends on a fresh line so tool activity below it starts cleanly. var reply strings.Builder flushReply := func() { if reply.Len() == 0 { return } rendered := ui.RenderMarkdown(reply.String()) fmt.Fprint(out, rendered) if !strings.HasSuffix(rendered, "\n") { fmt.Fprintln(out) } reply.Reset() } // The SessionEnd/PreCompact observer chains onto the existing OnEvent closure // (plugin notifier + telemetry). Nil when no hooks are configured. var hookEvent func(agentcore.AgentEvent) if deps.dispatcher != nil { hookEvent = hooks.NewHookNotifier(deps.dispatcher, deps.hookDeps.SessionID, deps.hookDeps.ProjectDir).Handle } _, err = runtime.DrainStream(ctx, stream, runtime.StreamHandler{ OnEvent: func(ev agentcore.AgentEvent) { // First call the notifier if present. if notifier := deps.notifierHandle(); notifier != nil { notifier(ev) } // Capture TelemetryEvent and fold it into the holder. if telemetry, ok := ev.(agentcore.TelemetryEvent); ok && deps.telemetry != nil { deps.telemetry.Fold(telemetry) } // Deliver SessionEnd/PreCompact to any configured hook. if hookEvent != nil { hookEvent(ev) } // Surface auto-compaction progress: a "Compacting conversation…" line // when it starts, then a token summary when it finishes. Manual // /compact prints its own summary via runManualCompact (it bypasses // the loop), so only threshold/overflow events reach here. switch e := ev.(type) { case agentcore.CompactionStartEvent: fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Compacting conversation…")) case agentcore.CompactionEvent: if e.ErrorMessage != "" { fmt.Fprintf(out, "compaction failed: %s\n", e.ErrorMessage) } else { fmt.Fprintf(out, "compacted: %d → %d tokens, summarized %d messages, kept %d\n", e.TokensBefore, e.TokensAfter, e.SummarizedCount, e.KeptCount) } } }, OnText: func(delta string) { reply.WriteString(delta) }, OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) { flushReply() for _, c := range msg.ToolCalls() { fmt.Fprintf(out, " %s %s\n", ui.Colorize(ui.Enabled(), ui.Green, "→ tool:"), ui.ToolCallLabel(c)) } for _, tr := range results { ui.RenderToolResult(out, tr) } // Surface a failed turn so a provider/API error is never silent. The // loop delivers request failures as a terminal assistant message with // stopReason error/aborted (they do not ride the run's result error), // so without this the REPL would print nothing at all for, e.g., a 4xx // from the endpoint or an empty/unparseable response. switch msg.StopReason { case agentcore.StopReasonError: reason := strings.TrimSpace(msg.ErrorMessage) if reason == "" { reason = "the provider returned an error with no message" } fmt.Fprintf(out, "%s %s\n", ui.Colorize(ui.Enabled(), ui.Red, "error:"), reason) case agentcore.StopReasonAborted: fmt.Fprintf(out, "%s aborted\n", ui.Colorize(ui.Enabled(), ui.Red, "error:")) default: // A turn that ends cleanly (end_turn) but produced no text, no // thinking, and no tool calls means the endpoint accepted the // request but sent back nothing usable (e.g. a 200 whose body was // not in the wire format this protocol expects). Say so instead of // returning to the prompt with no output. if len(msg.Content) == 0 && len(results) == 0 { fmt.Fprintf(out, "%s empty response from the model (no content). "+ "Check that --model, --base-url and --protocol match the same provider.\n", ui.Colorize(ui.Enabled(), ui.Yellow, "note:")) } } }, }) // A run can end (error or interrupt) with buffered text from a final turn // that never fired OnTurnEnd; flush it so no reply is silently dropped. flushReply() if err != nil { if ctx.Err() != nil { fmt.Fprintln(out, "^C interrupted") } else { fmt.Fprintf(out, "error: %v\n", err) } } } // runForkClone handles the /fork and /clone commands (US-006, #122), which // branch the conversation tree into a new, independent session. // // - "/clone" duplicates the entire current conversation at its current leaf // into a fresh session and switches to it. Appending to the clone never // affects the original (they are separate files). // - "/fork" with no argument lists the historical user messages, numbered, so // the user can pick one. // - "/fork N" branches from BEFORE the N-th listed user message: the new // session holds everything up to (but excluding) that message, so the user // re-prompts from that point on an independent branch. // // Both first persist the current session (so the fork copies a saved tree), // then call store.Fork to write the new branch, and finally swap deps.header and // deps.agentCtx to the new session in place so subsequent prompts continue on // the branch. resume of either session id later walks to its own leaf. func runForkClone(out io.Writer, deps *replDeps, line string) { // Persist the current turn as a branch so Fork copies an up-to-date tree // without flattening any existing branches (a plain Save would rewrite the // file linearly and drop siblings). cli.PersistTurn(out, deps) _, entries, err := deps.store.LoadEntries(deps.header.ID) if err != nil && !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(out, "pigo: cannot read session tree: %v\n", err) return } if len(entries) == 0 { fmt.Fprintln(out, "nothing to fork yet — send a message first") return } fields := strings.Fields(line) cmd := fields[0] var leafID string if cmd == "/clone" { // Clone the whole conversation at the current active leaf. leafID = deps.curLeaf if leafID == "" { leafID = entries[len(entries)-1].ID } } else { // /fork // Collect the historical user messages, in order, with their entry index. type userMsg struct { idx int // index into entries text string } var users []userMsg for i, e := range entries { if u, ok := e.Message.(agentcore.UserMessage); ok { users = append(users, userMsg{idx: i, text: agentcore.ContentToText(u.Content)}) } } if len(users) == 0 { fmt.Fprintln(out, "no user messages to fork from") return } if len(fields) < 2 { // List the user messages for selection. fmt.Fprintln(out, "fork from which message? run /fork :") for n, u := range users { fmt.Fprintf(out, " %d. %s\n", n+1, ui.OneLine(u.text)) } return } n, convErr := strconv.Atoi(fields[1]) if convErr != nil || n < 1 || n > len(users) { fmt.Fprintf(out, "invalid selection %q — run /fork to list messages (1..%d)\n", fields[1], len(users)) return } // Fork BEFORE the chosen user message: its parent becomes the new leaf, so // the copied branch excludes that message and everything after it. leafID = entries[users[n-1].idx].ParentID } newHeader, path, err := deps.store.Fork(deps.header.ID, leafID, time.Now().UTC()) if err != nil { fmt.Fprintf(out, "pigo: fork failed: %v\n", err) return } // Swap the live session to the new branch: rebuild the flat message list from // the copied path and point the REPL at the new header. Subsequent prompts // grow the branch, never the original. msgs := make(agentcore.MessageList, len(path)) for i, e := range path { msgs[i] = e.Message } deps.header = newHeader deps.agentCtx.Messages = msgs // The new file already holds the copied path verbatim, so mark it all // persisted and set the active leaf to the copied tip. deps.persisted = len(path) deps.curLeaf = "" if len(path) > 0 { deps.curLeaf = path[len(path)-1].ID } // A side thread branched from the old conversation is meaningless against the // new branch, so drop it (#281). deps.lastBtw = nil deps.lastBtwBase = 0 // Reset telemetry for the new session, since cumulative stats should not bleed // across conversations. if deps.telemetry != nil { deps.telemetry.Reset() } action := "cloned" if cmd == "/fork" { action = "forked" } fmt.Fprintf(out, "%s session %s → %s (%d messages)\n", action, newHeader.ParentSession, newHeader.ID, len(msgs)) } // runTree handles the /tree command (US-007, #123): the branch-navigation view // for a pure line REPL. // // - "/tree" with no argument persists the current turn, then prints the // session's entry tree with ├─/└─ connectors, tagging the active leaf with // "← current". Each printed row is numbered so it can be selected. // - "/tree N" switches the active leaf to the N-th printed entry: it rebuilds // the shared context to the root→leaf path feeding that entry, so the next // prompt continues from there. Because persistTurn appends new turns as a // branch descending from the (moved) leaf, continuing after a switch grows a // real sibling branch on disk rather than truncating history. func runTree(out io.Writer, deps *replDeps, line string) { // Persist any un-saved turn first so the tree reflects the live conversation. cli.PersistTurn(out, deps) _, entries, err := deps.store.LoadEntries(deps.header.ID) if err != nil && !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(out, "pigo: cannot read session tree: %v\n", err) return } if len(entries) == 0 { fmt.Fprintln(out, "session tree is empty — send a message first") return } lines := session.RenderTreeLines(entries, deps.curLeaf) fields := strings.Fields(line) if len(fields) < 2 { // Print the numbered tree for selection. fmt.Fprintln(out, "session tree (run /tree to switch the active branch):") for i, l := range lines { fmt.Fprintf(out, " %d. %s\n", i+1, l.Text) } return } n, convErr := strconv.Atoi(fields[1]) if convErr != nil || n < 1 || n > len(lines) { fmt.Fprintf(out, "invalid selection %q — run /tree to list nodes (1..%d)\n", fields[1], len(lines)) return } // Switch the active leaf to the chosen node and rebuild the context from its // root→leaf path. The chosen entry is already persisted, so persisted stays at // the rebuilt length; the next turn branches from curLeaf. target := lines[n-1].Entry path := session.PathToLeaf(entries, target.ID) msgs := make(agentcore.MessageList, len(path)) for i, e := range path { msgs[i] = e.Message } deps.agentCtx.Messages = msgs deps.curLeaf = target.ID deps.persisted = len(msgs) fmt.Fprintf(out, "switched to branch at node %d (%d messages) — next prompt continues from here\n", n, len(msgs)) } // pathCommandArg extracts the path argument for a "/cmd " line, enforcing a // command-token boundary (so "/exporter x" is NOT "/export" with arg "x") and // stripping a single layer of surrounding double quotes (so a path with spaces // can be given as /export "my session.html"). It returns "" when line is not the // given command or carries no argument. func pathCommandArg(line, cmd string) string { if line != cmd && !strings.HasPrefix(line, cmd+" ") { return "" } arg := strings.TrimSpace(strings.TrimPrefix(line, cmd)) if len(arg) >= 2 && strings.HasPrefix(arg, `"`) && strings.HasSuffix(arg, `"`) { arg = arg[1 : len(arg)-1] } return arg } // runExport handles the /export command (US-008, #124): it persists the live // turn, then writes the current session to a file. "/export" with no path // defaults to ".jsonl" in the working directory; "/export path.html" // (or .htm) writes a self-contained HTML transcript; any other extension writes // JSONL. The JSONL form round-trips losslessly through /import. func runExport(out io.Writer, deps *replDeps, line string) { cli.PersistTurn(out, deps) path := pathCommandArg(line, "/export") if path == "" { path = deps.header.ID + ".jsonl" } n, err := deps.store.Export(deps.header.ID, path) if err != nil { fmt.Fprintf(out, "pigo: export failed: %v\n", err) return } fmt.Fprintf(out, "exported %d entries to %s\n", n, path) } // runImport handles the /import command (US-008, #124): it reads a JSONL export // and materializes it as a fresh, independent session, then switches the live // REPL to it (swapping header + shared context in place) so the next prompt // continues the imported conversation. The original file is untouched; the new // session records the source id as its ParentSession. func runImport(out io.Writer, deps *replDeps, line string) { path := pathCommandArg(line, "/import") if path == "" { fmt.Fprintln(out, "usage: /import ") return } newHeader, entries, err := deps.store.Import(path, time.Now().UTC()) if err != nil { fmt.Fprintf(out, "pigo: import failed: %v\n", err) return } // Swap the live session to the imported one: rebuild the flat message list and // point the REPL at the new header. The imported file already holds the entries // verbatim, so mark them all persisted and set the active leaf to the tip. msgs := make(agentcore.MessageList, len(entries)) for i, e := range entries { msgs[i] = e.Message } deps.header = newHeader deps.agentCtx.Messages = msgs deps.persisted = len(entries) deps.curLeaf = "" if len(entries) > 0 { deps.curLeaf = entries[len(entries)-1].ID } // A side thread branched from the previous conversation no longer applies to // the imported one, so drop it (#281). deps.lastBtw = nil deps.lastBtwBase = 0 // Reset telemetry for the imported session, since cumulative stats should not // bleed across conversations. if deps.telemetry != nil { deps.telemetry.Reset() } fmt.Fprintf(out, "imported %d entries from %s → session %s\n", len(entries), path, newHeader.ID) } // runCopy handles the /copy command (US-009, #125): it copies the most recent // assistant text message to the system clipboard. When no clipboard utility is // available it degrades to printing the text with a notice, so the content is // never lost. An empty conversation (no assistant reply yet) is reported. func runCopy(out io.Writer, deps *replDeps) { text := "" for i := len(deps.agentCtx.Messages) - 1; i >= 0; i-- { if a, ok := deps.agentCtx.Messages[i].(agentcore.AssistantMessage); ok { if t := strings.TrimSpace(agentcore.ContentToText(a.Content)); t != "" { text = t break } } } if text == "" { fmt.Fprintln(out, "nothing to copy — no assistant reply yet") return } if err := clipboard.Copy(text); err != nil { if errors.Is(err, clipboard.ErrUnavailable) { // Degrade gracefully: print the text and tell the user why it was not // copied, so the content is still recoverable. fmt.Fprintln(out, "no clipboard utility found (install pbcopy/wl-copy/xclip/xsel); printing instead:") fmt.Fprintln(out, text) return } fmt.Fprintf(out, "pigo: copy failed: %v\n", err) return } fmt.Fprintf(out, "copied last reply to clipboard (%d chars)\n", len(text)) } // runSession handles the /session command (US-009, #125): it prints a summary of // the live session — id, message count, estimated token usage, model/provider, // creation time, and how many compaction checkpoints it contains. Counts are // derived from the in-memory context (the source of truth for the live turn) so // the numbers reflect unsaved messages too. func runSession(out io.Writer, deps *replDeps) { msgs := deps.agentCtx.Messages tokens := compaction.EstimateContextTokens(msgs).Tokens compactions := 0 for _, m := range msgs { if _, ok := m.(agentcore.CompactionMessage); ok { compactions++ } } fmt.Fprintf(out, "session: %s\n", deps.header.ID) fmt.Fprintf(out, "messages: %d\n", len(msgs)) fmt.Fprintf(out, "tokens (est): %d\n", tokens) model := deps.live.Model providerName := deps.live.ProviderName if model == "" { model = deps.header.Model } if providerName == "" { providerName = deps.header.Provider } fmt.Fprintf(out, "model: %s (provider: %s)\n", model, providerName) if !deps.header.CreatedAt.IsZero() { fmt.Fprintf(out, "created: %s\n", deps.header.CreatedAt.Format(time.RFC3339)) } fmt.Fprintf(out, "compactions: %d\n", compactions) } // runManualCompact compacts the shared context on an explicit /compact request: // it runs the summarization stream, replaces the context with the checkpoint + // retained tail, and prints the before/after token counts and retained message // count. A failure is reported but non-fatal — the original context is kept // unchanged (US-004). It uses the same provider/model as the live run. func runManualCompact(out io.Writer, deps replDeps) { msgs := deps.agentCtx.Messages settings := compaction.DefaultCompactionSettings before := compaction.EstimateContextTokens(msgs).Tokens stream := provider.StreamFnFromProvider(deps.live.Provider) model := provider.Model{Provider: deps.live.ProviderName, ID: deps.live.Model, ContextWindow: deps.live.ContextWindow} // Resolve the API key like a normal turn so summarization authenticates // against auth-requiring providers (otherwise Compact fails with // "missing API key" and /compact would always report a non-fatal failure). scfg := provider.StreamConfig{} if deps.creds != nil { scfg.APIKey = deps.creds.GetAPIKey(context.Background(), deps.live.ProviderName) } fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Compacting conversation…")) res, err := compaction.Compact(context.Background(), stream, model, msgs, settings, -1, nil, "", scfg) if err != nil { fmt.Fprintf(out, "compaction failed: %v (context left unchanged)\n", err) return } if res == nil { fmt.Fprintf(out, "nothing to compact (%d tokens, %d messages)\n", before, len(msgs)) return } now := time.Now().UnixMilli() rebuilt := res.RebuildContext(msgs, now) deps.agentCtx.Messages = rebuilt after := compaction.EstimateContextTokens(rebuilt).Tokens summarized := len(msgs) - (len(rebuilt) - 1) fmt.Fprintf(out, "compacted: %d → %d tokens, summarized %d messages, kept %d\n", before, after, summarized, len(rebuilt)-1) } // runManualRebuild reconstructs the shared context on an explicit /rebuild // request. It reloads the session's persisted checkpoint (from #480) and inserts // the compression boundary at its watermark — collapsing the pre-watermark // prefix into the checkpoint summary and preserving the recent tail verbatim. If // no checkpoint exists it falls back to the same lossy compaction /compact runs. // It prints the before/after token counts; a failure is reported but non-fatal // (the original context is kept unchanged). func runManualRebuild(out io.Writer, deps replDeps) { msgs := deps.agentCtx.Messages before := compaction.EstimateContextTokens(msgs).Tokens // Build a RunConfig matching a normal turn so the no-checkpoint fallback can // summarize against the live provider/model (RebuildFromCheckpoint reuses the // loop's compaction path). The checkpoint path itself is pure/local. cfg := runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: deps.live.Model, Provider: deps.live.ProviderName, ThinkingLevel: deps.live.ThinkingLevel, Stream: provider.StreamFnFromProvider(deps.live.Provider), ContextWindow: deps.live.ContextWindow, Compaction: compaction.DefaultCompactionSettings, }, } if deps.creds != nil { cfg.GetAPIKey = deps.creds.GetAPIKey } // Checkpoints live at /sessions//checkpoint.md; recover from // the same root streamRun writes to. Empty when memory is disabled — then // RebuildFromCheckpoint falls back to lossy compaction. memoryRoot := deps.memoryRoot fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Preparing conversation context…")) res, err := runtime.RebuildFromCheckpoint(context.Background(), msgs, deps.header.ID, memoryRoot, &cfg, nil) if err != nil { fmt.Fprintf(out, "rebuild failed: %v (context left unchanged)\n", err) return } if res.NoOp { fmt.Fprintf(out, "nothing to rebuild (%d tokens, %d messages)\n", before, len(msgs)) return } deps.agentCtx.Messages = res.Messages source := "checkpoint" if !res.FromCheckpoint { source = "compaction (no checkpoint)" } fmt.Fprintf(out, "rebuilt from %s: %d → %d tokens, collapsed %d messages, kept %d\n", source, res.TokensBefore, res.TokensAfter, res.SummarizedCount, res.KeptCount) } // replayTranscript prints a resumed session's prior messages to out so the user // sees the conversation so far before the first new prompt. func replayTranscript(out io.Writer, messages []agentcore.AgentMessage) { color := ui.Enabled() for _, m := range messages { switch msg := m.(type) { case agentcore.UserMessage: if t := agentcore.ContentToText(msg.Content); t != "" { fmt.Fprintf(out, "> %s\n", t) } case agentcore.AssistantMessage: if t := agentcore.ContentToText(msg.Content); t != "" { fmt.Fprintln(out, t) } for _, c := range msg.ToolCalls() { fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Green, "→ tool:"), ui.ToolCallLabel(c)) } case agentcore.ToolResultMessage: if msg.IsError { fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Red, "← error:"), ui.OneLine(agentcore.ContentToText(msg.Content))) } else { fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Green, "← result:"), ui.OneLine(agentcore.ContentToText(msg.Content))) } } } } // (Compact tool-activity renderers moved to internal/cli/ui: ui.RenderToolResult, // ui.ToolCallLabel, ui.OneLine — shared by the REPL, /btw and /goal.)