first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
// This file implements the /btw command (mirrors Claude Code's /btw and the pi
// agent extension @narumitw/pi-btw): a throwaway "side thread" for asking the
// model a quick side question that must NOT pollute the main conversation.
//
// /btw is intercepted in the REPL loop rather than routed through a slash Action
// closure because it must run an agent stream and read the live main context —
// none of which a pure string→string Action can do, exactly like /compact and
// /goal. It reaches the session's collaborators and mutable state through the
// cli.Host contract and reads follow-up lines through cli.Editor, so it need not
// import the concrete replDeps aggregate that assembles them.
//
// Isolation contract (the whole point of the feature): a side thread runs on a
// COPY of the main conversation as background, and its question/answer are only
// ever appended to that copy — never to host.AgentCtx().Messages. Nothing is
// persisted: no store.Save, no change to the persisted cursor / current leaf /
// header timestamp. Closing the side thread, switching sessions or restarting
// pigo discards everything.
//
// Scope: /btw is intercepted in the REPL loop and runs a side question against a
// copy of the main context (#279); it supports multi-turn follow-ups in the same
// ephemeral thread (#280), bare-/btw reopen of the most recent side thread this
// process (#281), and an optional model/thinking override config (#282, see
// btw_config.go) that affects only the side thread.
package btw
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/trust"
)
// btwHeader is the fixed banner shown when entering a side thread, so the user
// always knows the current input is a throwaway side question, not the main
// conversation (mirrors pi-btw's "btw · side thread" header).
const btwHeader = "btw · side thread"
// BtwHeader exposes the side-thread banner text so callers (and tests) can
// recognize it in output.
const BtwHeader = btwHeader
// btwPrompt is the input prompt shown for follow-up questions inside a side
// thread, distinguishing it from the main "pigo(model)>" prompt.
const btwPrompt = "btw> "
// RunBtw handles a /btw invocation. With an argument it starts a fresh side
// thread, asks that question, then enters a follow-up loop so the user can keep
// asking in the same ephemeral thread. Bare "/btw" reopens the most recent side
// thread from this process — replaying its Q&A history — and drops back into the
// follow-up loop; if none exists yet it guides the user to supply a question
// (US-004, #281). setCancel publishes the active run's cancel func so the REPL's
// SIGINT handler can interrupt the side run, reusing the same plumbing as a
// normal turn.
//
// The main context is never mutated: RunBtw builds a private side AgentContext
// seeded with a copy of the main messages, runs every turn against that copy,
// and returns without touching host.AgentCtx() or persisting anything. The side
// thread is retained in-process (host.LastBtw()) so a later bare /btw can reopen
// it, but it is never written to disk — restarting pigo discards it.
func RunBtw(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, line string) {
question := strings.TrimSpace(strings.TrimPrefix(line, "/btw"))
// Resolve the side thread's model/thinking once per invocation from the
// session defaults overlaid with btw.json (#282). Re-read each call so an
// edit takes effect next time with no restart.
settings := ResolveBtwSettings(out, host)
if question == "" {
// Bare /btw: reopen the most recent side thread if one exists this process,
// replaying its history; otherwise guide the user to supply a question.
if host.LastBtw() == nil {
fmt.Fprintln(out, "usage: /btw <question> — ask a quick side question without touching the main conversation")
return
}
printBtwHeader(out)
replaySideHistory(out, host.LastBtw(), host.LastBtwBase())
if editor != nil {
btwFollowUpLoop(setCancel, out, host, editor, host.LastBtw(), settings)
}
return
}
side := NewSideContext(host.AgentCtx())
// Remember this thread so a later bare /btw can reopen it. LastBtwBase marks
// where the copied background ends and the side Q&A begins, so a reopen only
// replays the side turns, not the whole main transcript.
host.SetLastBtw(side)
host.SetLastBtwBase(len(side.Messages))
printBtwHeader(out)
AskSide(setCancel, out, host, side, settings, question)
// Follow-up loop: keep answering in the same ephemeral thread until the user
// exits. A nil editor (direct test callers that only ask one question) skips
// the loop entirely, so a single /btw asks exactly one question and returns.
if editor != nil {
btwFollowUpLoop(setCancel, out, host, editor, side, settings)
}
}
// replaySideHistory prints the side thread's own Q&A (everything after the
// copied main-conversation background at index base) when a bare /btw reopens a
// prior thread, so the user can browse earlier answers before continuing. Only
// user questions and assistant text are shown; tool activity is omitted to keep
// the recap compact.
func replaySideHistory(out io.Writer, side *agentcore.AgentContext, base int) {
if base > len(side.Messages) {
base = len(side.Messages)
}
for _, msg := range side.Messages[base:] {
switch m := msg.(type) {
case agentcore.UserMessage:
fmt.Fprintf(out, "%s %s\n", ui.Colorize(ui.Enabled(), ui.Dim, "you:"), agentcore.ContentToText(m.Content))
case agentcore.AssistantMessage:
if text := agentcore.ContentToText(m.Content); text != "" {
rendered := ui.RenderMarkdown(text)
fmt.Fprint(out, rendered)
if !strings.HasSuffix(rendered, "\n") {
fmt.Fprintln(out)
}
}
}
}
}
// btwFollowUpLoop reads follow-up questions and answers them in the same side
// context, so each answer sees the prior side Q&A (FR-4). It exits on /exit,
// /quit, EOF, or an idle Ctrl+C (errLineInterrupted) — the same exit affordances
// as the main REPL, but confined to the side thread (FR-5). A blank line is
// ignored (stays in the thread). Nothing here touches the main context.
func btwFollowUpLoop(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, side *agentcore.AgentContext, settings BtwRunSettings) {
for {
raw, err := editor.ReadLine(btwPrompt)
if errors.Is(err, cli.ErrLineInterrupted) {
// Idle Ctrl+C at the side prompt leaves the thread (a Ctrl+C during a
// run is handled inside askSide via the SIGINT cancel plumbing).
fmt.Fprintln(out, "left side thread")
return
}
q := strings.TrimSpace(raw)
if err != nil && q == "" {
// EOF or read error with no partial line: leave the thread.
fmt.Fprintln(out, "left side thread")
return
}
if q == "/exit" || q == "/quit" {
fmt.Fprintln(out, "left side thread")
return
}
if q == "" {
continue
}
AskSide(setCancel, out, host, side, settings, q)
}
}
// NewSideContext builds the side thread's private AgentContext. Its Messages are
// a fresh slice seeded with a shallow COPY of the main messages (the elements
// are immutable value/interface messages, so a copied slice header is enough to
// guarantee appends to the side thread never reach the main context's Messages).
// The system prompt and tools are shared by value; only Messages diverges.
func NewSideContext(main *agentcore.AgentContext) *agentcore.AgentContext {
msgs := make(agentcore.MessageList, len(main.Messages))
copy(msgs, main.Messages)
return &agentcore.AgentContext{
SystemPrompt: main.SystemPrompt,
Messages: msgs,
Tools: main.Tools,
}
}
// printBtwHeader prints the side-thread banner.
func printBtwHeader(out io.Writer) {
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, btwHeader))
}
// AskSide appends the question to the side context and streams one answer,
// mirroring streamRun's rendering but targeting the side context so nothing is
// written back to the main conversation or to disk. It reuses the REPL's SIGINT
// cancel plumbing via setCancel. The model/provider/thinking come from settings
// (session defaults overlaid with btw.json, #282), never from host.Live(), so a
// /btw override cannot leak into the main session.
func AskSide(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, side *agentcore.AgentContext, settings BtwRunSettings, question string) {
content, err := ui.BuildUserContent(question)
if err != nil {
fmt.Fprintf(out, "pigo: %v\n", err)
return
}
side.Messages = append(side.Messages, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: content,
})
runCtx, cancel := context.WithCancel(context.Background())
setCancel(cancel)
defer func() {
cancel()
setCancel(nil)
}()
// Show a transient status while the model works (FR-9). It is printed on its
// own line; the streamed answer follows below it.
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Answering…"))
cfg := runtime.RunConfig{
LoopConfig: runtime.LoopConfig{
Model: settings.Model,
Provider: settings.ProviderName,
ThinkingLevel: settings.ThinkingLevel,
Stream: provider.StreamFnFromProvider(settings.Provider),
GetAPIKey: host.Creds().GetAPIKey,
ContextWindow: host.Live().ContextWindow,
Compaction: compaction.DefaultCompactionSettings,
},
Batch: agenttool.BatchConfig{
ToolExecutorConfig: agenttool.ToolExecutorConfig{
Registry: host.Registry(),
BeforeToolCall: trust.BeforeToolCall(host.Trust(), host.Cwd(), host.Input(), out, host.ConfirmMu()),
},
},
Reminders: host.Reminders(),
}
// Wire the per-turn hook seams onto the side run's cfg; nil dispatcher is a
// no-op (FR-18).
if d := host.Dispatcher(); d != nil {
run.InstallSeams(&cfg, d, host.HookDeps())
}
stream := runtime.StartRun(runCtx, side, cfg)
drainSideStream(runCtx, out, host, stream)
}
// chainBtwEvent returns the OnEvent observer for a /btw side run: the plugin
// notifier, with the SessionEnd/PreCompact hook notifier chained after it when
// hooks are configured, mirroring the REPL's OnEvent composition.
func chainBtwEvent(host cli.Host) func(agentcore.AgentEvent) {
notifier := host.NotifierHandle()
d := host.Dispatcher()
if d == nil {
return notifier
}
deps := host.HookDeps()
hookEvent := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir).Handle
if notifier == nil {
return hookEvent
}
return func(ev agentcore.AgentEvent) {
notifier(ev)
hookEvent(ev)
}
}
// drainSideStream prints the streamed assistant text and tool activity of a side
// run, mirroring streamRun/drainGoalStream. It blocks until the run ends. Unlike
// the main loop it persists nothing.
func drainSideStream(ctx context.Context, out io.Writer, host cli.Host, stream *runtime.LoopEventStream) {
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()
}
_, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{
OnEvent: chainBtwEvent(host),
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)
}
},
})
flushReply()
if err != nil {
if ctx.Err() != nil {
// A Ctrl+C during the run cancels just this answer; the follow-up loop
// then returns to the btw prompt so the user can ask again or exit with
// another Ctrl+C (FR-5).
fmt.Fprintln(out, "^C interrupted — answer cancelled")
} else {
fmt.Fprintf(out, "error: %v\n", err)
}
}
}