1package agent
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "strings"
11 "time"
12
13 "github.com/patriceckhart/zot/internal/agent/extensions"
14 "github.com/patriceckhart/zot/internal/agent/modes"
15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"
17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/tui"
21)
22
23// interactiveExtHooks is a tiny adapter that lets the extension
24// manager call back into the Interactive instance built later in
25// runInteractive. The forward-declared *modes.Interactive is filled
26// in immediately after manager construction.
27type interactiveExtHooks struct {
28 ivPtr **modes.Interactive
29}
30
31func (h *interactiveExtHooks) iv() *modes.Interactive {
32 if h == nil || h.ivPtr == nil {
33 return nil
34 }
35 return *h.ivPtr
36}
37
38func (h *interactiveExtHooks) Notify(extName, level, message string) {
39 if iv := h.iv(); iv != nil {
40 iv.Notify(extName, level, message)
41 }
42}
43func (h *interactiveExtHooks) Submit(text string) {
44 if iv := h.iv(); iv != nil {
45 iv.Submit(text)
46 }
47}
48func (h *interactiveExtHooks) Insert(text string) {
49 if iv := h.iv(); iv != nil {
50 iv.Insert(text)
51 }
52}
53func (h *interactiveExtHooks) Display(extName, text string) {
54 if iv := h.iv(); iv != nil {
55 iv.Display(extName, text)
56 }
57}
58
59// extToolAdapter bridges *extensions.Manager to the
60// ExtensionToolSource interface declared in build.go (kept narrow to
61// avoid a build->extensions import cycle). One adapter instance per
62// run; used at every Resolve point so re-built agents pick up the
63// same set of extension tools.
64type extToolAdapter struct {
65 mgr *extensions.Manager
66}
67
68func (a *extToolAdapter) Tools() []ExtensionToolInfo {
69 infos := a.mgr.Tools()
70 out := make([]ExtensionToolInfo, len(infos))
71 for i, t := range infos {
72 out[i] = ExtensionToolInfo{
73 Extension: t.Extension,
74 Name: t.Name,
75 Description: t.Description,
76 Schema: t.Schema,
77 }
78 }
79 return out
80}
81
82func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
83 return extensions.NewTool(a.mgr, extensions.ToolInfo{
84 Extension: info.Extension,
85 Name: info.Name,
86 Description: info.Description,
87 Schema: info.Schema,
88 })
89}
90
91// fanoutAgentEvent translates a core.AgentEvent into the wire-format
92// EventFromHost and pushes it through the extension manager. Only
93// the events that have a clear extension-facing meaning are
94// forwarded; internal-only ones (text_delta, tool_progress) are
95// dropped to keep the per-extension stream sane.
96func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
97 if mgr == nil {
98 return
99 }
100 switch e := ev.(type) {
101 case core.EvTurnStart:
102 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
103 case core.EvToolCall:
104 mgr.EmitEvent(extproto.EventFromHost{
105 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
106 })
107 case core.EvAssistantMessage:
108 // Concat the visible text portions of the message; binary
109 // blocks (tool_use, etc.) are skipped because subscribers
110 // usually want a string they can grep / display.
111 var text string
112 for _, c := range e.Message.Content {
113 if tb, ok := c.(provider.TextBlock); ok {
114 text += tb.Text
115 }
116 }
117 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
118 case core.EvTurnEnd:
119 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
120 if e.Err != nil {
121 ev.Error = e.Err.Error()
122 }
123 mgr.EmitEvent(ev)
124 }
125}
126
127// Run is the top-level entrypoint for the zot binary.
128func Run(rawArgs []string, version string) error {
129 // Subcommand router: `zot bot ...` is handled separately so the
130 // generic flag parser doesn't reject "bot" as a positional arg.
131 if handled, err := runBotCommand(rawArgs, version); handled {
132 return err
133 }
134 if handled, err := runExtCommand(rawArgs); handled {
135 return err
136 }
137 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
138 // spawn the binary with a clean argv. Strip the leading 'rpc'
139 // token and let the rest flow through the normal arg parser.
140 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
141 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
142 }
143
144 args, err := ParseArgs(rawArgs)
145 if err != nil {
146 PrintHelp(version)
147 return err
148 }
149 if args.Help {
150 PrintHelp(version)
151 return nil
152 }
153 if args.Version {
154 fmt.Println("zot", version)
155 return nil
156 }
157 // Model catalog: load any cached discovery data before we inspect
158 // the model list (list-models, print/json, interactive).
159 LoadCachedModels()
160
161 if args.ListModels {
162 printModels()
163 return nil
164 }
165
166 ctx := context.Background()
167
168 // Kick an async refresh of the live model catalog. The first run of
169 // zot hits the network; subsequent runs within CacheTTL do nothing.
170 RefreshModelsAsync()
171
172 switch args.Mode {
173 case ModePrint:
174 return runPrintMode(ctx, args, version)
175 case ModeJSON:
176 return runJSONMode(ctx, args, version)
177 case ModeRPC:
178 return runRPCMode(ctx, args, version)
179 default:
180 return runInteractive(ctx, args, version)
181 }
182}
183
184// ---- print / json modes: require credentials, run single-shot ----
185
186// nonInteractiveExtHooks is the HostHooks impl used by print / json
187// modes. They have no TUI, so notify / display go to stderr and
188// submit / insert are no-ops (the extension can't steer a
189// single-shot run once it's in flight anyway).
190type nonInteractiveExtHooks struct{}
191
192func (nonInteractiveExtHooks) Notify(ext, level, message string) {
193 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
194}
195func (nonInteractiveExtHooks) Submit(string) {}
196func (nonInteractiveExtHooks) Insert(string) {}
197func (nonInteractiveExtHooks) Display(string, string) {}
198
199// setupNonInteractiveExtensions loads --ext paths and (unless
200// --no-ext) runs discovery. Returns the manager so the caller can
201// wire tools into the resolved registry, and a cleanup closure to
202// defer. Mirrors the interactive-mode setup minus the TUI hooks.
203func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
204 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
205 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
206 fmt.Fprintln(os.Stderr, "extension load:", e)
207 }
208 if !args.NoExt {
209 for _, e := range extMgr.Discover(ctx) {
210 fmt.Fprintln(os.Stderr, "extension load:", e)
211 }
212 }
213 extMgr.WaitForReady(3 * time.Second)
214 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
215 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
216 return extMgr, func() { extMgr.Stop(2 * time.Second) }
217}
218
219// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
220// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
221// interactive path wires up, so extensions get their normal
222// event-intercept surface in print / json / rpc flows too.
223func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
224 if ag == nil || extMgr == nil {
225 return
226 }
227 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
228 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
229 if res.Block {
230 return false, res.Reason, nil
231 }
232 return true, "", res.ModifiedArgs
233 }
234 ag.BeforeTurn = func(step int) (bool, string) {
235 res := extMgr.InterceptTurnStart(ctx, step)
236 return !res.Block, res.Reason
237 }
238 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
239 res := extMgr.InterceptAssistantMessage(ctx, text)
240 if res.Block {
241 return false, res.Reason, ""
242 }
243 return true, "", res.ReplaceText
244 }
245 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
246}
247
248func runPrintMode(ctx context.Context, args Args, version string) error {
249 if args.NoYolo {
250 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
251 }
252 r, err := Resolve(args, true)
253 if err != nil {
254 return err
255 }
256 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
257 defer stopExt()
258
259 ag := r.NewAgent()
260 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
261 sess, _ := openOrCreateSession(args, r, ag, version)
262 defer sess.Close()
263
264 prompt := args.Prompt
265 if prompt == "" {
266 piped, _ := readAllStdin()
267 prompt = strings.TrimSpace(piped)
268 }
269 if prompt == "" {
270 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
271 }
272
273 start := len(ag.Messages())
274 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
275 WriteNewTranscript(ag, sess, start)
276 return err
277}
278
279func runJSONMode(ctx context.Context, args Args, version string) error {
280 if args.NoYolo {
281 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
282 }
283 r, err := Resolve(args, true)
284 if err != nil {
285 return err
286 }
287 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
288 defer stopExt()
289
290 ag := r.NewAgent()
291 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
292 sess, _ := openOrCreateSession(args, r, ag, version)
293 defer sess.Close()
294
295 prompt := args.Prompt
296 if prompt == "" {
297 piped, _ := readAllStdin()
298 prompt = strings.TrimSpace(piped)
299 }
300 if prompt == "" {
301 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
302 }
303
304 start := len(ag.Messages())
305 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
306 WriteNewTranscript(ag, sess, start)
307 return err
308}
309
310// ---- interactive mode: opens the TUI even without credentials ----
311
312func runInteractive(ctx context.Context, args Args, version string) error {
313 // Resolve WITHOUT requiring credentials.
314 r, err := Resolve(args, false)
315 if err != nil {
316 return err
317 }
318
319 authStore := AuthStoreFor()
320 mgr := auth.NewManager(authStore)
321 defer mgr.Close()
322
323 // Keep the sandbox pointer stable across agent rebuilds (login / model
324 // switch). The Interactive UI toggles the lock via this pointer, and
325 // rebuilt tool instances must share the same one so the lock sticks.
326 sharedSandbox := r.Sandbox
327
328 // Build the extension manager BEFORE the agent so we can fold
329 // extension-defined tools into the registry. Forward-declare iv so
330 // the host hooks adapter can dereference it after construction.
331 var iv *modes.Interactive
332 extHooks := &interactiveExtHooks{ivPtr: &iv}
333 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
334 // --ext paths first so they win against installed extensions of
335 // the same name (loadOne's first-write-wins semantics).
336 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
337 fmt.Fprintln(os.Stderr, "extension load:", e)
338 }
339 // --no-ext skips the global + project-local discovery scan;
340 // explicit --ext paths above are still honoured so you can run
341 // "only this extension" with --no-ext --ext ./x.
342 if !args.NoExt {
343 for _, e := range extMgr.Discover(ctx) {
344 fmt.Fprintln(os.Stderr, "extension load:", e)
345 }
346 }
347 // Wait briefly for extensions to flush their initial register_tool
348 // frames before we build the agent's tool registry. Half a second
349 // is plenty for any extension that's actually well-behaved; ones
350 // that don't send a ready frame eat the full grace and proceed.
351 // 3s is the per-extension grace period for the ready frame.
352 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
353 // from cold cache. The wait is tight only for extensions that
354 // haven't sent ready by then; ones that signalled earlier release
355 // the wait immediately.
356 extMgr.WaitForReady(3 * time.Second)
357 defer extMgr.Stop(2 * time.Second)
358
359 extToolAdapter := &extToolAdapter{mgr: extMgr}
360 r.MergeExtensionTools(extToolAdapter)
361
362 // Confirmation gate: when --no-yolo is on, the agent must ask
363 // the user before every tool call. In interactive mode the TUI
364 // provides the Confirmer; in print/json/rpc modes there's no
365 // way to prompt, so the gate is constructed with a nil inner
366 // which auto-refuses every call with a helpful reason.
367 var confirmGate *core.ConfirmGate
368 if args.NoYolo {
369 confirmGate = core.NewConfirmGate(nil) // set below for interactive
370 }
371
372 // Capture current args in a closure so BuildAgent can re-resolve
373 // after a successful login (picks up the newly stored credential).
374 wireAgentExt := func(a *core.Agent) *core.Agent {
375 if a == nil {
376 return a
377 }
378 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
379 // Confirm gate runs FIRST: if the user refused, we don't
380 // waste extension-intercept time or let guards see the call.
381 if confirmGate != nil {
382 ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
383 if !ok {
384 return false, reason, nil
385 }
386 }
387 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
388 if r.Block {
389 return false, r.Reason, nil
390 }
391 return true, "", r.ModifiedArgs
392 }
393 a.BeforeTurn = func(step int) (bool, string) {
394 r := extMgr.InterceptTurnStart(ctx, step)
395 return !r.Block, r.Reason
396 }
397 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
398 r := extMgr.InterceptAssistantMessage(ctx, text)
399 if r.Block {
400 return false, r.Reason, ""
401 }
402 return true, "", r.ReplaceText
403 }
404 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
405 return a
406 }
407
408 buildAgent := func() (*core.Agent, string, string, error) {
409 resolved, err := Resolve(args, true)
410 if err != nil {
411 return nil, "", "", err
412 }
413 resolved.UseSandbox(sharedSandbox)
414 resolved.MergeExtensionTools(extToolAdapter)
415 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
416 }
417
418 // Rebuild agent with an explicit provider/model override.
419 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
420 next := args
421 if providerOverride != "" {
422 next.Provider = providerOverride
423 }
424 if modelOverride != "" {
425 next.Model = modelOverride
426 }
427 resolved, err := Resolve(next, true)
428 if err != nil {
429 return nil, "", "", err
430 }
431 resolved.UseSandbox(sharedSandbox)
432 resolved.MergeExtensionTools(extToolAdapter)
433 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
434 }
435
436 var ag *core.Agent
437 if r.HasCredential() {
438 ag = wireAgentExt(r.NewAgent())
439 }
440
441 // /reload-ext callback: after the manager has respawned every
442 // extension, re-resolve the tool registry (built-ins + freshly-
443 // registered extension tools) and swap it onto the current
444 // agent in-place. The current agent may have been replaced by a
445 // /model swap since spawn, so re-read the live `ag` on each
446 // invocation.
447 extMgr.SetOnReload(func() {
448 current := ag
449 if current == nil {
450 return
451 }
452 resolved, err := Resolve(args, true)
453 if err != nil {
454 return
455 }
456 resolved.UseSandbox(sharedSandbox)
457 resolved.MergeExtensionTools(extToolAdapter)
458 current.SetTools(resolved.ToolRegistry)
459 })
460
461 // Fire session_start once we know the manager's running.
462 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
463
464 var sess *core.Session
465 var sessBaselineMsgs int // messages already on disk when current session opened
466 if !args.NoSess && ag != nil {
467 sess, _ = openOrCreateSession(args, r, ag, version)
468 if ag != nil {
469 sessBaselineMsgs = len(ag.Messages())
470 }
471 }
472 defer func() {
473 if sess != nil {
474 sess.Close()
475 }
476 }()
477
478 // loadSession replaces the current session with the one at path and
479 // hands its messages to the agent. Used by the /sessions picker.
480 loadSession := func(path string) error {
481 currentAg := ag // captured
482 if currentAg == nil {
483 return fmt.Errorf("no agent running; log in first")
484 }
485 newSess, msgs, err := core.OpenSession(path)
486 if err != nil {
487 return err
488 }
489 // Flush any unsaved messages to the old session before swapping.
490 if sess != nil {
491 WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
492 _ = sess.Close()
493 }
494 sess = newSess
495 currentAg.SetMessages(msgs)
496 sessBaselineMsgs = len(msgs)
497 return nil
498 }
499
500 term := tui.NewProcTerm()
501
502 // Kick off the async update check so the banner can appear when the
503 // http response eventually arrives (usually <1s on cached DNS). Map
504 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
505 updateCh := make(chan modes.UpdateInfo, 1)
506 go func() {
507 defer close(updateCh)
508 src := <-CheckForUpdateAsync(ZotHome(), version)
509 updateCh <- modes.UpdateInfo{
510 Current: src.Current,
511 Latest: src.Latest,
512 Available: src.Available,
513 URL: src.URL,
514 }
515 }()
516
517 // Changelog: when the running version differs from the last
518 // version whose release notes the user dismissed, fetch the
519 // release body from GitHub and have the TUI show it once. On
520 // first-ever launch (no prior LastChangelogShown), seed the
521 // stored version silently — don't dump release notes at someone
522 // who just installed.
523 changelogCh := make(chan modes.ChangelogPayload, 1)
524 go func() {
525 defer close(changelogCh)
526 cfg, _ := LoadConfig()
527 if cfg.LastChangelogShown == "" {
528 SeedChangelogVersion(version)
529 return
530 }
531 if !ShouldShowChangelog(version, cfg) {
532 return
533 }
534 info := <-FetchChangelogAsync(version)
535 if info.Body == "" {
536 return
537 }
538 changelogCh <- modes.ChangelogPayload{
539 Version: info.Version,
540 Body: info.Body,
541 URL: info.URL,
542 }
543 }()
544
545 iv = modes.NewInteractive(modes.InteractiveConfig{
546 Terminal: term,
547 Theme: tui.Dark,
548 Model: r.Model,
549 Provider: r.Provider,
550 AuthMethod: r.AuthMethod,
551 BaseURL: r.BaseURL,
552 Reasoning: r.Reasoning,
553 SystemPrompt: r.SystemPrompt,
554 Tools: r.ToolRegistry,
555 MaxSteps: r.MaxSteps,
556 CWD: r.CWD,
557 ZotHome: ZotHome(),
558 Version: version,
559 UpdateInfoChan: updateCh,
560 Sandbox: sharedSandbox,
561 Agent: ag,
562 InitialInput: args.Prompt,
563 AuthManager: mgr,
564 BuildAgent: buildAgent,
565 BuildAgentFor: buildAgentFor,
566 LoadSession: loadSession,
567 Extensions: extMgr,
568 ChangelogChan: changelogCh,
569 OnChangelogDismiss: func() {
570 _ = MarkChangelogShown(version)
571 },
572 SkillSnapshot: func() []*skills.Skill {
573 if args.NoSkill {
574 // --no-skill: nothing for the picker to show.
575 return nil
576 }
577 // Re-discover so the picker reflects edits made during
578 // the session. Cheap; SKILL.md files are small. Filter
579 // out built-in skills — they're hidden from user-facing
580 // surfaces because they're implementation detail; the
581 // model still sees them through the system-prompt
582 // manifest and the skill tool. User skills only appear
583 // when --with-skills is set; without it the picker shows
584 // nothing but the model still has the built-ins.
585 userHome, _ := os.UserHomeDir()
586 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
587 return skills.VisibleSkills(list)
588 },
589 NoYolo: args.NoYolo,
590 ConfirmGate: confirmGate,
591 PersistModel: func(providerName, model string) {
592 // Update config.json so next launch uses the same pick.
593 cfg, _ := LoadConfig()
594 cfg.Provider = providerName
595 cfg.Model = model
596 _ = SaveConfig(cfg)
597 // Update the active session's meta so resume picks this up.
598 if sess != nil {
599 _ = sess.UpdateModel(providerName, model)
600 }
601 },
602 })
603
604 // Bind the interactive TUI as the Confirmer. We deferred this
605 // until now because the gate is constructed before the TUI
606 // (the BeforeToolExecute closure captures it). SetConfirmer
607 // is mutex-guarded on the gate so this is safe.
608 if confirmGate != nil {
609 confirmGate.SetConfirmer(iv)
610 }
611
612 runErr := iv.Run(ctx)
613
614 // Flush final transcript to session (only if we had / ended up with an agent).
615 if finalAg := iv.Agent(); finalAg != nil && sess != nil {
616 WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
617 }
618 return runErr
619}
620
621// openOrCreateSession returns a session for the run. sess may be nil
622// with a nil error if session persistence is disabled.
623func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
624 if args.NoSess {
625 return nil, nil
626 }
627 // Sweep meta-only files left over from older zot versions (and from
628 // any session that crashed before its first AppendMessage). Cheap;
629 // reads the first few bytes of each file in the cwd's session dir.
630 core.PruneEmptySessions(ZotHome(), args.CWD)
631 var (
632 s *core.Session
633 msgs []provider.Message
634 err error
635 )
636 switch {
637 case args.Session != "":
638 s, msgs, err = core.OpenSession(args.Session)
639 case args.Continue:
640 latest := core.LatestSession(ZotHome(), args.CWD)
641 if latest != "" {
642 s, msgs, err = core.OpenSession(latest)
643 }
644 case args.Resume:
645 picked, perr := pickSession(args.CWD)
646 if perr != nil {
647 return nil, perr
648 }
649 if picked != "" {
650 s, msgs, err = core.OpenSession(picked)
651 }
652 }
653 if err != nil {
654 return nil, err
655 }
656 if s != nil {
657 ag.SetMessages(msgs)
658 return s, nil
659 }
660 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
661}
662
663func pickSession(cwd string) (string, error) {
664 files := core.ListSessions(ZotHome(), cwd)
665 if len(files) == 0 {
666 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
667 return "", nil
668 }
669 for i, f := range files {
670 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
671 }
672 fmt.Fprint(os.Stderr, "pick #: ")
673 rd := bufio.NewReader(os.Stdin)
674 line, _ := rd.ReadString('\n')
675 line = strings.TrimSpace(line)
676 var n int
677 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
678 return "", fmt.Errorf("invalid selection")
679 }
680 return files[n-1], nil
681}
682
683// WriteNewTranscript appends only messages after index `from` from the
684// agent's transcript to the session.
685func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
686 if sess == nil || ag == nil {
687 return
688 }
689 msgs := ag.Messages()
690 for i := from; i < len(msgs); i++ {
691 _ = sess.AppendMessage(msgs[i])
692 }
693 cum := ag.Cost()
694 _ = sess.AppendUsage(cum, cum)
695}
696
697func readAllStdin() (string, error) {
698 fi, err := os.Stdin.Stat()
699 if err != nil {
700 return "", err
701 }
702 if (fi.Mode() & os.ModeCharDevice) != 0 {
703 return "", nil
704 }
705 b, err := io.ReadAll(os.Stdin)
706 return string(b), err
707}
708
709func printModels() {
710 fmt.Println("provider model id context max-out reasoning source name")
711 for _, m := range provider.Active() {
712 reason := " "
713 if m.Reasoning {
714 reason = "✓"
715 }
716 source := m.Source
717 if source == "" {
718 source = "catalog"
719 }
720 if m.Speculative {
721 source = "speculative"
722 }
723 fmt.Printf("%-10s %-30s %8d %8d %s %-11s %s\n",
724 m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
725 }
726}