1151 lines
47 KiB
Go
1151 lines
47 KiB
Go
// 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
|
|
// <system-reminder> 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 <memoryRoot>/sessions/<id>/, 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 <n>" 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 <n>" 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 <n>:")
|
|
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 <n> 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 <path>" 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 "<session-id>.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 <path.jsonl>")
|
|
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 <memoryRoot>/sessions/<id>/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.)
|