v0.1.81 1aea23e

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10
11	"github.com/patriceckhart/zot/internal/agent/tools"
12	"github.com/patriceckhart/zot/internal/core"
13	"github.com/patriceckhart/zot/internal/provider"
14	"github.com/patriceckhart/zot/internal/skills"
15)
16
17// Resolved is the effective configuration after merging CLI, config, defaults.
18type Resolved struct {
19	Provider   string
20	Model      string
21	Credential string // api key or oauth access token
22	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
23	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
24	BaseURL    string
25	CWD        string
26	Reasoning  string
27
28	ToolRegistry core.Registry
29	ToolSummary  []ToolSummary
30	SystemPrompt string
31	MaxSteps     int
32	Sandbox      *tools.Sandbox
33
34	// SkillTool is the on-demand skill loader registered with the
35	// agent's tool registry, or nil if no SKILL.md files were
36	// discovered. Exposed so the tui can list / preview skills.
37	SkillTool *skills.Tool
38
39	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
40	// so the system prompt can be rebuilt later without re-running
41	// resolve.
42	systemAppend     []string
43	systemCustom     string
44	toolDescriptions map[string]string
45}
46
47// HasCredential reports whether a credential was resolved.
48func (r Resolved) HasCredential() bool { return r.Credential != "" }
49
50// MergeExtensionTools folds every tool registered by an extension
51// into r's ToolRegistry and re-renders the system prompt's tool
52// summary so the model sees both built-in and extension tools.
53//
54// Idempotent: calling twice with the same manager state has no
55// effect on the second pass (existing names are preserved). Built-in
56// tools always win on conflict.
57func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
58	if mgr == nil {
59		return
60	}
61	infos := mgr.Tools()
62	if len(infos) == 0 {
63		return
64	}
65	changed := false
66	for _, info := range infos {
67		if _, exists := r.ToolRegistry[info.Name]; exists {
68			continue
69		}
70		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
71		changed = true
72	}
73	if !changed {
74		return
75	}
76	// Re-render the system prompt with the merged tool list. Skill
77	// addendum is preserved by walking the existing append slice.
78	append_ := r.systemAppend
79	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
80		CWD:    r.CWD,
81		Tools:  toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
82		Custom: r.systemCustom,
83		Append: append_,
84	})
85}
86
87// ExtensionToolSource is the slice of the extension manager that
88// MergeExtensionTools needs. Lives here as an interface so the
89// build package doesn't import internal/agent/extensions (which
90// imports core, which imports... avoid the cycle).
91type ExtensionToolSource interface {
92	Tools() []ExtensionToolInfo
93	NewExtensionTool(info ExtensionToolInfo) core.Tool
94}
95
96// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
97// ExtensionToolSource here without importing the extensions
98// package. The cli wires a tiny adapter to bridge them.
99type ExtensionToolInfo struct {
100	Extension   string
101	Name        string
102	Description string
103	Schema      []byte
104}
105
106// toolSummariesFromRegistry rebuilds the system-prompt tool list
107// from a (possibly extended) registry, using cached descriptions for
108// the human-readable summary text.
109func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
110	out := make([]ToolSummary, 0, len(reg))
111	for name, t := range reg {
112		desc := t.Description()
113		if d, ok := cached[name]; ok && d != "" {
114			desc = d
115		}
116		out = append(out, ToolSummary{Name: name, Description: desc})
117	}
118	return out
119}
120
121// defaultModelForProvider returns the model id zot prefers when the
122// caller didn't pick one. Mirrors the per-provider switch used at
123// multiple points in Resolve; centralised so the unknown-model
124// recovery path and the no-model-configured path can't drift.
125//
126// Returns the empty string for "ollama", which has no built-in
127// default — the caller is expected to special-case ollama and
128// error or use whatever the user passed.
129func defaultModelForProvider(prov string) string {
130	switch prov {
131	case "openai":
132		return "gpt-5"
133	case "kimi":
134		return "kimi-for-coding"
135	case "deepseek":
136		return "deepseek-v4-pro"
137	case "google":
138		return "gemini-2.5-pro"
139	case "ollama":
140		return ""
141	default:
142		return provider.DefaultModel.ID
143	}
144}
145
146// Resolve merges args, config, and env into a Resolved set.
147//
148// Unlike the earlier version, Resolve NEVER returns an error for
149// missing credentials: the TUI can start without them and launch a
150// login flow. requireCred controls whether missing credentials are a
151// hard error (used by print/json modes).
152func Resolve(args Args, requireCred bool) (Resolved, error) {
153	cfg, _ := LoadConfig()
154
155	// User-requested provider (explicit > config > default).
156	provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
157	if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" {
158		// Unknown provider (maybe removed or renamed). Fall back to
159		// the first provider that has credentials, or anthropic.
160		provName = "anthropic"
161		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
162			provName = "openai"
163		}
164		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
165			provName = "kimi"
166		}
167		if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
168			provName = "deepseek"
169		}
170		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
171			provName = "anthropic"
172		}
173		// Reset the saved config so this doesn't keep happening.
174		cfg.Provider = provName
175		cfg.Model = ""
176		_ = SaveConfig(cfg)
177	}
178
179	var (
180		cred      string
181		method    string
182		accountID string
183		credErr   error
184	)
185	if provName == "ollama" {
186		if args.BaseURL == "" {
187			args.BaseURL = "http://localhost:11434"
188		}
189		cred = firstNonEmpty(args.APIKey, "ollama")
190		method = "apikey"
191	} else {
192		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
193	}
194
195	// If the user did NOT explicitly pick a provider and the default one
196	// has no credentials, auto-fall-back to whichever provider is actually
197	// logged in. That way running plain `zot` after `/login` (any provider)
198	// never shows a "not logged in" banner.
199	userPickedProvider := args.Provider != ""
200	if credErr != nil && !userPickedProvider && provName != "ollama" {
201		for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} {
202			if other == provName {
203				continue
204			}
205			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
206				provName = other
207				cred, method, accountID, credErr = c, m, a, err
208				break
209			}
210		}
211	}
212
213	model := firstNonEmpty(args.Model, cfg.Model)
214	if model == "" {
215		switch provName {
216		case "openai":
217			model = "gpt-5"
218		case "kimi":
219			model = "kimi-for-coding"
220		case "deepseek":
221			model = "deepseek-v4-pro"
222		case "google":
223			model = "gemini-2.5-pro"
224		case "ollama":
225			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
226		default:
227			model = provider.DefaultModel.ID
228		}
229	}
230	// If the resolved model belongs to a different provider (e.g. config
231	// says gpt-5 but we fell back to anthropic), pick that provider's default.
232	if provName != "ollama" {
233		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
234			switch provName {
235			case "openai":
236				model = "gpt-5"
237			case "kimi":
238				model = "kimi-for-coding"
239			case "deepseek":
240				model = "deepseek-v4-pro"
241			case "google":
242				model = "gemini-2.5-pro"
243			default:
244				model = provider.DefaultModel.ID
245			}
246		}
247	}
248	resolvedModel, err := provider.FindModel(provName, model)
249	if err != nil && provName == "ollama" {
250		// ollama is intentionally open-catalogue: any model id the
251		// local server understands is valid, even if not in the
252		// baked-in catalog.
253		resolvedModel = provider.Model{
254			Provider:      "ollama",
255			ID:            model,
256			DisplayName:   model,
257			ContextWindow: 32768,
258			MaxOutput:     8192,
259			BaseURL:       args.BaseURL,
260			Source:        "ollama",
261		}
262		err = nil
263	}
264	if err != nil {
265		// The model the user (or persisted config) asked for is no
266		// longer in the active catalogue — they probably removed it
267		// from their models.json or upgraded zot and the id changed.
268		// Refusing to launch is the wrong move: it strands the user
269		// with no way to even open the TUI and pick a new model.
270		// Fall back to the provider's default, warn on stderr, and,
271		// when the stale id came from the persisted config (not an
272		// explicit --model flag), repair the config so the warning
273		// doesn't repeat on every launch.
274		fallback := defaultModelForProvider(provName)
275		fm, ferr := provider.FindModel(provName, fallback)
276		if ferr != nil {
277			// Even the provider default is gone (catastrophic
278			// catalogue trim). Last resort: any model on this
279			// provider, then the global DefaultModel.
280			if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
281				fm = candidates[0]
282				ferr = nil
283			} else {
284				fm = provider.DefaultModel
285				ferr = nil
286			}
287		}
288		fmt.Fprintf(os.Stderr,
289			"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
290			model, fm.ID)
291		if args.Model == "" && cfg.Model == model {
292			cfg.Model = fm.ID
293			_ = SaveConfig(cfg)
294		}
295		resolvedModel = fm
296		model = fm.ID
297	}
298
299	// If the model defines a base URL (e.g. local ollama) and the
300	// user didn't pass --base-url, use the model's URL.
301	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
302		args.BaseURL = resolvedModel.BaseURL
303	}
304
305	// If the model has a base URL, credentials are optional (local
306	// models like ollama don't need real API keys).
307	if resolvedModel.BaseURL != "" && credErr != nil {
308		cred = "ollama"
309		credErr = nil
310		requireCred = false
311	}
312
313	if credErr != nil && requireCred {
314		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
315			credErr, envVarName(provName))
316	}
317
318	sandbox := tools.NewSandbox(args.CWD)
319	reg := buildToolRegistry(args, args.CWD, sandbox)
320
321	// Skill discovery: scan project + global locations + built-in
322	// skills shipped with the binary. If any are found, register
323	// the on-demand `skill` loader tool plus a system-prompt
324	// manifest so the model knows what's available.
325	//
326	// --no-skill bypasses the entire mechanism: no manifest in the
327	// system prompt, no `skill` tool in the registry. Useful for a
328	// clean-room run with zero extra context biasing the model.
329	var (
330		discovered    []*skills.Skill
331		skillTool     *skills.Tool
332		skillAddendum string
333	)
334	if !args.NoSkill {
335		homeDir, _ := os.UserHomeDir()
336		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
337		if len(discovered) > 0 {
338			skillTool = skills.NewTool(discovered)
339			reg[skillTool.Name()] = skillTool
340			skillAddendum = skills.SystemPromptAddendum(discovered)
341		}
342	}
343	_ = skillTool
344
345	summaries := toolSummaries(reg, args)
346
347	append_ := append([]string(nil), args.AppendSystemPrompt...)
348	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
349		append_ = append(append_, agentsAddendum)
350	}
351	if skillAddendum != "" {
352		append_ = append(append_, skillAddendum)
353	}
354
355	// Custom system prompt resolution order:
356	//   1. --system-prompt flag (highest priority; ad-hoc per run)
357	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
358	//   3. built-in default (defaultIdentity + defaultGuidelines)
359	custom := args.SystemPrompt
360	if custom == "" {
361		custom = readUserSystemPrompt(ZotHome())
362	}
363
364	sys := BuildSystemPrompt(SystemPromptOpts{
365		CWD:    args.CWD,
366		Tools:  summaries,
367		Custom: custom,
368		Append: append_,
369	})
370
371	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
372
373	max := args.MaxSteps // 0 = unlimited
374
375	return Resolved{
376		Provider:         provName,
377		Model:            model,
378		Credential:       cred,
379		AuthMethod:       method,
380		AccountID:        accountID,
381		BaseURL:          args.BaseURL,
382		CWD:              args.CWD,
383		Reasoning:        reasoning,
384		ToolRegistry:     reg,
385		ToolSummary:      summaries,
386		SystemPrompt:     sys,
387		MaxSteps:         max,
388		Sandbox:          sandbox,
389		SkillTool:        skillTool,
390		systemAppend:     append_,
391		systemCustom:     custom,
392		toolDescriptions: descMapFromSummaries(summaries),
393	}, nil
394}
395
396// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
397// trimmed contents, or "" when the file is missing / unreadable /
398// empty. Errors are intentionally swallowed: the file is optional,
399// and any failure to read it should fall back to the built-in
400// default system prompt rather than crash the run.
401func readUserSystemPrompt(zotHome string) string {
402	if zotHome == "" {
403		return ""
404	}
405	path := filepath.Join(zotHome, "SYSTEM.md")
406	raw, err := os.ReadFile(path)
407	if err != nil {
408		return ""
409	}
410	return strings.TrimSpace(string(raw))
411}
412
413// readAgentsContext loads optional AGENTS.md instruction files. No
414// default file is created or required: zot only includes files that
415// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
416// followed by project instructions from the top-most parent down to cwd.
417func readAgentsContext(cwd, zotHome string) string {
418	type contextFile struct {
419		path    string
420		content string
421	}
422	var files []contextFile
423	seen := map[string]bool{}
424	add := func(path string) {
425		if path == "" {
426			return
427		}
428		abs, err := filepath.Abs(path)
429		if err == nil {
430			path = abs
431		}
432		if seen[path] {
433			return
434		}
435		raw, err := os.ReadFile(path)
436		if err != nil {
437			return
438		}
439		content := strings.TrimSpace(string(raw))
440		if content == "" {
441			return
442		}
443		seen[path] = true
444		files = append(files, contextFile{path: path, content: content})
445	}
446	addFirstFromDir := func(dir string) {
447		if dir == "" {
448			return
449		}
450		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
451			path := filepath.Join(dir, name)
452			if _, err := os.Stat(path); err == nil {
453				add(path)
454				return
455			}
456		}
457	}
458
459	addFirstFromDir(zotHome)
460
461	if cwd != "" {
462		abs, err := filepath.Abs(cwd)
463		if err == nil {
464			cwd = abs
465		}
466		var dirs []string
467		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
468			dirs = append(dirs, dir)
469			parent := filepath.Dir(dir)
470			if parent == dir {
471				break
472			}
473		}
474		for i := len(dirs) - 1; i >= 0; i-- {
475			addFirstFromDir(dirs[i])
476		}
477	}
478
479	if len(files) == 0 {
480		return ""
481	}
482	var sb strings.Builder
483	sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n")
484	for _, f := range files {
485		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
486	}
487	return strings.TrimSpace(sb.String())
488}
489
490// descMapFromSummaries indexes the human-readable descriptions for
491// the renderToolsSection rebuild path.
492func descMapFromSummaries(summaries []ToolSummary) map[string]string {
493	out := make(map[string]string, len(summaries))
494	for _, s := range summaries {
495		out[s.Name] = s.Description
496	}
497	return out
498}
499
500// NewClient returns a provider.Client for r, choosing the auth mode
501// based on r.AuthMethod. Panics if no credential is present; callers
502// must check HasCredential() first.
503func (r Resolved) NewClient() provider.Client {
504	if !r.HasCredential() {
505		panic("NewClient called without credential; check HasCredential first")
506	}
507	switch r.Provider {
508	case "ollama":
509		return provider.NewOpenAI(r.Credential, r.BaseURL)
510	case "kimi":
511		inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
512		if r.AuthMethod == "oauth" {
513			return r.wrapWithRefresh(inner)
514		}
515		return inner
516	case "deepseek":
517		return provider.NewDeepSeek(r.Credential, r.BaseURL)
518	case "openai":
519		if r.AuthMethod == "oauth" {
520			inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
521			return r.wrapWithRefresh(inner)
522		}
523		return provider.NewOpenAI(r.Credential, r.BaseURL)
524	case "google":
525		// API-key only path. Gemini Generative Language API.
526		return provider.NewGemini(r.Credential, r.BaseURL)
527	default:
528		if r.AuthMethod == "oauth" {
529			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
530			return r.wrapWithRefresh(inner)
531		}
532		return provider.NewAnthropic(r.Credential, r.BaseURL)
533	}
534}
535
536// wrapWithRefresh wraps an OAuth client so the access token is
537// refreshed automatically before each API call. Without this, long
538// sessions (hours) silently fail when the 1-hour token expires.
539func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
540	provName := r.Provider
541	baseURL := r.BaseURL
542	accountID := r.AccountID
543
544	refreshFn := func(ctx context.Context) (string, error) {
545		tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
546		if err != nil {
547			return "", err
548		}
549		return tok.AccessToken, nil
550	}
551
552	factory := func(token string) provider.Client {
553		switch provName {
554		case "openai":
555			return provider.NewOpenAICodex(token, accountID, baseURL)
556		case "kimi":
557			return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
558		default:
559			return provider.NewAnthropicOAuth(token, baseURL)
560		}
561	}
562
563	return provider.NewRefreshingClient(inner, refreshFn, factory)
564}
565
566// UseSandbox replaces the sandbox pointer that every tool in r's
567// registry references. Used to keep the /jail state stable across
568// agent rebuilds (e.g. /login, /model switching providers).
569func (r *Resolved) UseSandbox(s *tools.Sandbox) {
570	if s == nil || r == nil {
571		return
572	}
573	r.Sandbox = s
574	for name, t := range r.ToolRegistry {
575		switch v := t.(type) {
576		case *tools.ReadTool:
577			v.Sandbox = s
578		case *tools.WriteTool:
579			v.Sandbox = s
580		case *tools.EditTool:
581			v.Sandbox = s
582		case *tools.BashTool:
583			v.Sandbox = s
584		}
585		_ = name
586	}
587}
588
589// NewAgent constructs a core.Agent from r. Requires a credential.
590func (r Resolved) NewAgent() *core.Agent {
591	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
592	a.MaxSteps = r.MaxSteps
593	a.Reasoning = r.Reasoning
594	return a
595}
596
597func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
598	if args.NoTools {
599		return core.Registry{}
600	}
601	all := map[string]core.Tool{
602		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
603		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
604		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
605		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
606	}
607	reg := core.Registry{}
608	if len(args.Tools) == 0 {
609		for _, t := range all {
610			reg[t.Name()] = t
611		}
612		return reg
613	}
614	for _, name := range args.Tools {
615		if t, ok := all[name]; ok {
616			reg[name] = t
617		}
618	}
619	return reg
620}
621
622func toolSummaries(reg core.Registry, args Args) []ToolSummary {
623	order := []string{"read", "write", "edit", "bash"}
624	var out []ToolSummary
625	for _, name := range order {
626		if t, ok := reg[name]; ok {
627			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
628		}
629	}
630	return out
631}
632
633func firstNonEmpty(vals ...string) string {
634	for _, v := range vals {
635		if v != "" {
636			return v
637		}
638	}
639	return ""
640}
641
642func kimiCodeHeaders() map[string]string {
643	host, _ := os.Hostname()
644	if host == "" {
645		host = "unknown"
646	}
647	deviceID := ""
648	if home, err := os.UserHomeDir(); err == nil {
649		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
650			deviceID = strings.TrimSpace(string(b))
651		}
652	}
653	if deviceID == "" {
654		deviceID = "zot"
655	}
656	return map[string]string{
657		"User-Agent":         "KimiCLI/1.41.0",
658		"X-Msh-Platform":     "kimi_cli",
659		"X-Msh-Version":      "1.41.0",
660		"X-Msh-Device-Name":  host,
661		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
662		"X-Msh-Os-Version":   runtime.GOOS,
663		"X-Msh-Device-Id":    deviceID,
664	}
665}
666
667func envVarName(provider string) string {
668	switch provider {
669	case "openai":
670		return "OPENAI"
671	case "kimi":
672		return "KIMI"
673	case "deepseek":
674		return "DEEPSEEK"
675	case "google":
676		return "GEMINI"
677	case "ollama":
678		return "OLLAMA"
679	default:
680		return "ANTHROPIC"
681	}
682}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close