Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
51 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "abdul-hamid-achik/sonar", "files": {"specs/tui_smoke.yml": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068225.3487532, "n": 1987, "h": "368f531c4fbaa4b1", "c": "version: 1\ncontractHash: sha256:4c9f0e9d1529017ba37ce36a8c4d89d314aee4a211c25451b6303e2ae1bf3e36\nname: sonar_tui_smoke\nintent: |\n a user can launch the sonar TUI, see the initial screen, and quit with ctrl+c.\ntarget:\n # Driven through the fake DeepSeek fixture. Pointing at the real\n # binary made this spec depend on an ambient DEEPSEEK_API_KEY: it\n # passed where one was exported and exited 1 in CI. See\n # specs/fixtures/fake_deepseek.\n cmd:\n - \"${projectRoot}/.glyphrun/tmp/fake-deepseek\"\n - \"${projectRoot}/bin/sonar\"\n - \"${projectRoot}/.glyphrun/tmp/smoke-protocol.txt\"\n cwd: \".\"\n env:\n HOME: \"${projectRoot}/.glyphrun/tmp/home\"\n SONAR_AGENTS_DIR: \"${projectRoot}/.glyphrun/tmp/agents\"\n OLLAMA_HOST: \"http://127.0.0.1:1\"\npreconditions:\n commands:\n - run: \"rm -rf .glyphrun/tmp/smoke-protocol.txt .glyphrun/tmp/home .glyphrun/tmp/agents && mkdir -p .glyphrun/tmp/home/.config/sonar .glyphrun/tmp/agents && printf '%s\\\\n' 'model:' ' auto_select: false' 'ice:' ' enabled: false' > .glyphrun/tmp/home/.config/sonar/config.yaml && go build -o ./bin/sonar ./cmd/sonar && go build -o .glyphrun/tmp/fake-deepseek ./specs/fixtures/fake_deepseek\"\n cwd: \".\"\n timeoutMs: 30000\nsteps:\n - wait:\n screen:\n contains: \"DEEPSEEK_API_KEY\"\n timeoutMs: 8000\n - snapshot: launch\n - press: \"ctrl+c\"\n - wait:\n process:\n exited: true\n timeoutMs: 8000\noutcomes:\n - id: launch_snapshot_stable\n description: the normal-width welcome and the missing-credential guidance remain stable\n verify:\n snapshot:\n name: launch\n mode: text\n - id: welcome_visible\n description: launch names the resolved provider and model\n verify:\n screen:\n contains: \"deepseek\"\n - id: ctrl_c_quits\n description: ctrl+c exits the TUI cleanly\n verify:\n process:\n exitCode: 0\n"}}, "specs/fixtures/fake_ollama_json_receipt/main.go": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068290.173509, "n": 9545, "h": "50be40be3f633d61", "c": "// Command fake-ollama-json-receipt hosts a deterministic Ollama endpoint,\n// runs one headless `sonar --json -p` turn against it, and validates the\n// emitted sonar.turn-receipt.v1 document exactly: schema identity,\n// closed status/stop reason, provider usage, aggregated timings, and the\n// model digest bound from the live inventory. The Glyphrun spec asserts the\n// written verdict file, keeping the receipt contract black-box tested.\npackage main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tfixtureModel = \"qwen3.5:2b\"\n\tfixtureDigest = \"sha256:1f2e3d4c5b6a79881f2e3d4c5b6a79881f2e3d4c5b6a79881f2e3d4c5b6a7988\"\n\tfixtureAnswer = \"durable receipt turn complete\"\n)\n\nfunc main() {\n\tos.Exit(run())\n}\n\nfunc run() int {\n\tif len(os.Args) != 3 {\n\t\tfmt.Fprintln(os.Stderr, \"usage: fake-ollama-json-receipt SONAR_BINARY VERDICT_PATH\")\n\t\treturn 2\n\t}\n\tbinary, verdictPath := os.Args[1], os.Args[2]\n\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"fake Ollama listen: %v\\n\", err)\n\t\treturn 1\n\t}\n\tvar chatCalls int\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/v1/models\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = fmt.Fprintf(w, `{\"data\":[{\"id\":%q}]}`, fixtureModel)\n\t})\n\tmux.HandleFunc(\"/v1/chat/completions\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tchatCalls++\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tif chatCalls > 1 {\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t_, _ = fmt.Fprint(w, `{\"error\":{\"message\":\"unexpected extra chat request\"}}`)\n\t\t\treturn\n\t\t}\n\t\t// Flush the headers, then pause before the first token. Both halves\n\t\t// matter: the client starts its clock when response headers arrive, so\n\t\t// sleeping before the flush would delay the clock too and leave\n\t\t// time-to-first-token at zero. This models a real provider — headers\n\t\t// immediately, first token later — and on loopback it is the only way\n\t\t// the assertion can tell a real measurement from an unset field.\n\t\tif flusher, ok := w.(http.Flusher); ok {\n\t\t\tflusher.Flush()\n\t\t}\n\t\ttime.Sleep(15 * time.Millisecond)\n\t\t_, _ = fmt.Fprintf(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":%q}}]}\\n\\n\", fixtureAnswer)\n\t\t_, _ = fmt.Fprint(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":7,\\\"completion_tokens\\\":5}}\\n\\n\")\n\t\t_, _ = fmt.Fprint(w, \"data: [DONE]\\n\\n\")\n\t})\n\tserver := &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}\n\tserveDone := make(chan error, 1)\n\tgo func() { serveDone <- server.Serve(listener) }()\n\n\tvar childStdout bytes.Buffer\n\tcommand := exec.Command(binary, \"--json\", \"-p\", \"emit one durable receipt\", \"-model\", fixtureModel)\n\tcommand.Stdin = os.Stdin\n\tcommand.Stdout = io.MultiWriter(&childStdout, os.Stdout)\n\tcommand.Stderr = os.Stderr\n\t// `ollama` is a hosted OpenAI-compatible provider now, so the fixture\n\t// serves that wire shape on loopback and points the provider profile at\n\t// itself. It used to fake the native /api protocol against OLLAMA_HOST,\n\t// which no longer reaches anything.\n\tcommandEnv := replaceEnv(hermeticEnv(), \"SONAR_PROVIDER\", \"ollama\")\n\tcommandEnv = replaceEnv(commandEnv, \"SONAR_PROVIDER_BASE_URL\", \"http://\"+listener.Addr().String()+\"/v1\")\n\tcommandEnv = replaceEnv(commandEnv, \"SONAR_PROVIDER_MODEL\", fixtureModel)\n\tcommandEnv = replaceEnv(commandEnv, \"SONAR_PROVIDER_API_KEY_ENV\", \"FAKE_OLLAMA_API_KEY\")\n\tcommandEnv = replaceEnv(commandEnv, \"FAKE_OLLAMA_API_KEY\", \"fixture-key-never-leaves-loopback\")\n\tcommand.Env = commandEnv\n\tchildErr := command.Run()\n\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)\n\t_ = server.Shutdown(shutdownCtx)\n\tcancel()\n\tif serveErr := <-serveDone; serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {\n\t\tfmt.Fprintf(os.Stderr, \"serve fake Ollama: %v\\n\", serveErr)\n\t}\n\n\tfailures := validateReceipt(childStdout.String())\n\tif childErr != nil {\n\t\tfailures = append(failures, fmt.Sprintf(\"sonar exited with error: %v\", childErr))\n\t}\n\tverdict := \"ok\\n\"\n\tif len(failures) > 0 {\n\t\tverdict = \"fail\\n\" + strings.Join(failures, \"\\n\") + \"\\n\"\n\t}\n\tif err := os.WriteFile(verdictPath, []byte(verdict), 0o600); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"write verdict: %v\\n\", err)\n\t\treturn 1\n\t}\n\tif len(failures) > 0 {\n\t\tfor _, failure := range failures {\n\t\t\tfmt.Fprintln(os.Stderr, \"receipt check failed: \"+failure)\n\t\t}\n\t\treturn 1\n\t}\n\treturn 0\n}\n\n// validateReceipt enforces the sonar.turn-receipt.v1 contract on the\n// child's stdout: exactly one newline-terminated JSON document with the exact\n// facts this fixture's provider reported.\nfunc validateReceipt(stdout string) []string {\n\tvar failures []string\n\tcheck := func(ok bool, format string, args ...any) {\n\t\tif !ok {\n\t\t\tfailures = append(failures, fmt.Sprintf(format, args...))\n\t\t}\n\t}\n\ttrimmed := strings.TrimSpace(stdout)\n\tcheck(trimmed != \"\", \"stdout carried no receipt document\")\n\tcheck(strings.Count(trimmed, \"\\n\") == 0, \"stdout must carry exactly one document, got %q\", trimmed)\n\tvar receipt struct {\n\t\tSchema string `json:\"schema\"`\n\t\tRunID string `json:\"run_id\"`\n\t\tTurnID string `json:\"turn_id\"`\n\t\tStatus string `json:\"status\"`\n\t\tStopReason string `json:\"stop_reason\"`\n\t\tTruncated bool `json:\"truncated\"`\n\t\tText string `json:\"text\"`\n\t\tUsage struct {\n\t\t\tPromptTokens int64 `json:\"prompt_tokens\"`\n\t\t\tEvalTokens int64 `json:\"eval_tokens\"`\n\t\t} `json:\"usage\"`\n\t\tTiming *struct {\n\t\t\tTTFTMS int64 `json:\"ttft_ms\"`\n\t\t\tEvalMS int64 `json:\"eval_ms\"`\n\t\t\tTotalMS int64 `json:\"total_ms\"`\n\t\t} `json:\"timing\"`\n\t\tModel struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tDigest string `json:\"digest\"`\n\t\t\tNumCtx int `json:\"num_ctx\"`\n\t\t} `json:\"model\"`\n\t\tSession *struct {\n\t\t\tWorkspace string `json:\"workspace\"`\n\t\t} `json:\"session\"`\n\t\tToolCalls []json.RawMessage `json:\"tool_calls\"`\n\t}\n\tif err := json.Unmarshal([]byte(trimmed), &receipt); err != nil {\n\t\treturn append(failures, fmt.Sprintf(\"stdout is not one valid JSON document: %v\", err))\n\t}\n\tcheck(receipt.Schema == \"sonar.turn-receipt.v1\", \"schema = %q\", receipt.Schema)\n\tcheck(strings.HasPrefix(receipt.RunID, \"run_\"), \"run_id = %q\", receipt.RunID)\n\tcheck(strings.HasPrefix(receipt.TurnID, \"turn_\"), \"turn_id = %q\", receipt.TurnID)\n\tcheck(receipt.Status == \"settled\", \"status = %q\", receipt.Status)\n\tcheck(receipt.StopReason == \"completed\", \"stop_reason = %q\", receipt.StopReason)\n\tcheck(!receipt.Truncated, \"truncated must be false for a stop finish\")\n\tcheck(receipt.Text == fixtureAnswer, \"text = %q\", receipt.Text)\n\tcheck(receipt.Usage.EvalTokens == 5 && receipt.Usage.PromptTokens == 7,\n\t\t\"usage = %d/%d, want 5/7\", receipt.Usage.EvalTokens, receipt.Usage.PromptTokens)\n\t// A hosted provider reports no per-phase durations, so eval_ms stays zero\n\t// by contract (\"not reported\", never \"instant\"). Time-to-first-token and\n\t// total are client-measured and must be present and positive — they were\n\t// absent from every hosted turn until the OpenAI-compatible dialect started\n\t// measuring them, which made this the one field the local-daemon fixture\n\t// alone could satisfy.\n\tif receipt.Timing == nil {\n\t\tfailures = append(failures, \"timing block is missing\")\n\t} else {\n\t\tcheck(receipt.Timing.TTFTMS > 0, \"ttft_ms = %d, want a client-measured value\", receipt.Timing.TTFTMS)\n\t\tcheck(receipt.Timing.TotalMS > 0, \"total_ms = %d, want a client-measured value\", receipt.Timing.TotalMS)\n\t\tcheck(receipt.Timing.TotalMS >= receipt.Timing.TTFTMS,\n\t\t\t\"total_ms %d is shorter than ttft_ms %d\", receipt.Timing.TotalMS, receipt.Timing.TTFTMS)\n\t}\n\tcheck(receipt.Model.Name == fixtureModel, \"model.name = %q\", receipt.Model.Name)\n\t// No model.digest: a weight digest is a fact about locally installed\n\t// weights, and sonar reaches hosted models only.\n\tcheck(receipt.Model.NumCtx > 0, \"model.num_ctx = %d\", receipt.Model.NumCtx)\n\tcheck(receipt.Session != nil && receipt.Session.Workspace != \"\", \"session workspace is missing\")\n\tcheck(receipt.ToolCalls != nil, \"tool_calls must serialize as an array\")\n\treturn failures\n}\n\n// hermeticEnv strips every provider credential and provider override the\n// ambient shell may carry before this fixture declares its own.\n//\n// Without it a spec is not hermetic. This suite runs on machines where\n// DEEPSEEK_API_KEY is routinely exported, os.Environ() passes it straight\n// through, and sonar then configures a real hosted provider the spec never\n// asked for — so the fake server owns the model inventory while a different\n// provider dispatches. Observed: a top bar reading \"DEEPSEEK · remote prompts\n// · qwen3.5:0.8b\", the same spec passing or failing depending on whether a key\n// happened to be exported, and a test run reaching a metered endpoint with a\n// real credential.\n//\n// A deterministic terminal suite must not be able to bill you.\nfunc hermeticEnv() []string {\n\tenvironment := os.Environ()\n\tresult := make([]string, 0, len(environment))\n\tfor _, entry := range environment {\n\t\tkey, _, found := strings.Cut(entry, \"=\")\n\t\tif !found {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasPrefix(key, \"SONAR_PROVIDER\") ||\n\t\t\tstrings.HasSuffix(key, \"_API_KEY\") ||\n\t\t\tstrings.HasSuffix(key, \"_API_TOKEN\") {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, entry)\n\t}\n\treturn result\n}\n\nfunc replaceEnv(environ []string, key, value string) []string {\n\tresult := make([]string, 0, len(environ)+1)\n\tfor _, entry := range environ {\n\t\tif !strings.HasPrefix(entry, key+\"=\") {\n\t\t\tresult = append(result, entry)\n\t\t}\n\t}\n\treturn append(result, key+\"=\"+value)\n}\n"}}, "internal/llm/deepseek.go": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068294.5988376, "n": 8903, "h": "f0a017c1e4dfb4e2", "c": "package llm\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/abdul-hamid-achik/sonar/internal/catalog\"\n\t\"github.com/abdul-hamid-achik/sonar/internal/config\"\n)\n\n// DeepSeek is sonar's only inference backend. Every fact below is taken from\n// the official API contract (api-docs.deepseek.com) rather than inferred from\n// the generic OpenAI shape, because three of them differ in ways that break an\n// agent loop:\n//\n// - Thinking is toggled by `{\"thinking\": {\"type\": ...}}`, not by\n// reasoning_effort. reasoning_effort only grades depth once thinking is on.\n// - Thinking defaults to ENABLED. A harness that never sends the toggle pays\n// for chain-of-thought on every turn.\n// - An assistant message that carries tool calls must echo its own\n// reasoning_content back on all later requests, or the API answers 400.\n//\n// See OpenAICompatibleClient's DialectDeepSeek handling for the wire details.\nconst (\n\t// DeepSeekBaseURL is the documented OpenAI-format endpoint. DeepSeek also\n\t// serves an Anthropic-format surface at /anthropic, which sonar does not use.\n\tDeepSeekBaseURL = \"https://api.deepseek.com\"\n\n\t// DeepSeekAPIKeyEnv names the environment variable holding the key. Only\n\t// the NAME is ever configured; the value is read from the process\n\t// environment so a secret can never land in YAML.\n\tDeepSeekAPIKeyEnv = \"DEEPSEEK_API_KEY\"\n\n\t// DeepSeekFlashModel is the model sonar pins to. Its current published\n\t// build is DeepSeek-V4-Flash-0731 (284B total / 13B active).\n\tDeepSeekFlashModel = \"deepseek-v4-flash\"\n\n\t// DeepSeekContextWindow is the 1M-token context DeepSeek serves by default.\n\tDeepSeekContextWindow = 1_000_000\n\n\t// DeepSeekMaxOutputTokens is the published generation ceiling.\n\tDeepSeekMaxOutputTokens = 384_000\n\n\t// DeepSeekDefaultEffort matches the API default for ordinary requests.\n\t// \"low\" and \"medium\" are mapped to \"high\" server-side; \"xhigh\" maps to \"max\".\n\tDeepSeekDefaultEffort = \"high\"\n\n\t// DeepSeekMaxEffort is the deepest setting, which DeepSeek selects\n\t// automatically for some agent clients.\n\tDeepSeekMaxEffort = \"max\"\n)\n\n// DeepSeekPricing is the published per-million-token rate card, in USD.\n// Cache hits are ~50x cheaper than misses, so prompt-prefix stability is the\n// single biggest cost lever in a long agent session.\n//\n// DeepSeek has announced peak/off-peak pricing that doubles every billing item\n// during 09:00–12:00 and 14:00–18:00 Beijing time (UTC+8). These constants are\n// the off-peak/regular rates; any cost surface must present them as an\n// estimate, never as a settled charge.\nconst (\n\tDeepSeekInputCacheHitUSDPerMTok = 0.0028\n\tDeepSeekInputCacheMissUSDPerMTok = 0.14\n\tDeepSeekOutputUSDPerMTok = 0.28\n)\n\n// DeepSeekOptions configures the pinned DeepSeek client.\ntype DeepSeekOptions struct {\n\t// APIKey is the resolved secret value, read from the environment by the\n\t// caller. Required: sonar has no unauthenticated mode.\n\tAPIKey string\n\t// Model defaults to DeepSeekFlashModel. Any other value is rejected.\n\tModel string\n\t// BaseURL overrides the endpoint for a gateway or proxy that speaks the\n\t// same dialect. Defaults to DeepSeekBaseURL.\n\tBaseURL string\n\t// Thinking enables chain-of-thought. Defaults to true via NewDeepSeekClient,\n\t// matching the API default.\n\tThinking bool\n\t// ReasoningEffort grades thinking depth. Defaults to DeepSeekDefaultEffort.\n\tReasoningEffort string\n}\n\n// ResolveProviderModel fills in a provider's default model when the caller\n// left it empty, and otherwise passes the requested model through.\n//\n// sonar runs many models; DeepSeek Flash is only the default. An id the\n// catalog does not list is accepted rather than rejected: the catalog is a\n// pinned snapshot, so refusing unlisted models would make the harness unusable\n// the day a provider ships a new one. The cost is that such a model has no\n// catalog-derived context window or pricing, and surfaces that depend on those\n// must degrade rather than assume — see catalog.FindModel's miss path.\nfunc ResolveProviderModel(providerType, model string) (string, error) {\n\tid := catalog.ProviderID(config.NormalizedProviderType(providerType))\n\tprovider, known := catalog.LookupProvider(id)\n\tif !known {\n\t\t// A private endpoint the catalog does not describe. Nothing to validate\n\t\t// against, so the caller's model stands.\n\t\treturn strings.TrimSpace(model), nil\n\t}\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\tif fallback := strings.TrimSpace(provider.DefaultSmallModelID); fallback != \"\" {\n\t\t\treturn fallback, nil\n\t\t}\n\t\treturn strings.TrimSpace(provider.DefaultLargeModelID), nil\n\t}\n\treturn model, nil\n}\n\n// NewDeepSeekClient builds the pinned DeepSeek chat client.\nfunc NewDeepSeekClient(opts DeepSeekOptions) (*OpenAICompatibleClient, error) {\n\tkey := strings.TrimSpace(opts.APIKey)\n\tif key == \"\" {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"%s is unset or empty; sonar requires a DeepSeek API key (export %s, or inject it at launch)\",\n\t\t\tDeepSeekAPIKeyEnv,\n\t\t\tDeepSeekAPIKeyEnv,\n\t\t)\n\t}\n\tmodel, err := ResolveProviderModel(config.ProviderTypeDeepSeek, opts.Model)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbaseURL := strings.TrimSpace(opts.BaseURL)\n\tif baseURL == \"\" {\n\t\tbaseURL = DeepSeekBaseURL\n\t}\n\teffort := strings.ToLower(strings.TrimSpace(opts.ReasoningEffort))\n\tif effort == \"\" {\n\t\teffort = DeepSeekDefaultEffort\n\t}\n\treturn NewOpenAICompatibleClient(OpenAICompatibleOptions{\n\t\tBaseURL: baseURL,\n\t\tModel: model,\n\t\tAPIKey: key,\n\t\tDialect: DialectDeepSeek,\n\t\tThinking: opts.Thinking,\n\t\tReasoningEffort: effort,\n\t})\n}\n\n// NewProviderClient builds the chat client for a resolved provider profile.\n//\n// The dialect is chosen from the provider's identity, not from its endpoint\n// shape. The catalog calls DeepSeek \"openai-compat\", which is true about the\n// URL and wrong about the request contract: it needs a thinking toggle and a\n// reasoning_content round-trip no generic OpenAI client sends. Selecting on\n// wire type alone would produce a client that connects, answers once, and then\n// fails every tool-call turn with a 400.\n//\n// The anthropic-family branch below is the one place selecting by wire type\n// would actually have been safe — Catwalk's \"anthropic\" type genuinely means\n// \"speaks the real Anthropic Messages API\" for all four providers that carry\n// it — but identity is still used, for the same reason as DeepSeek: it keeps\n// dialect selection independent of how Catwalk chooses to classify a provider\n// upstream, and it is one line longer than a wire-type switch would have been.\n//\n// Both the startup path and the runtime /provider switch go through here so\n// the two can never disagree about which dialect a provider gets.\nfunc NewProviderClient(providerType, baseURL, model, apiKey string) (RemoteChatClient, error) {\n\tnormalized := config.NormalizedProviderType(providerType)\n\tresolved, err := ResolveProviderModel(normalized, model)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tswitch {\n\tcase normalized == config.ProviderTypeDeepSeek:\n\t\tclient, err := NewDeepSeekClient(DeepSeekOptions{\n\t\t\tAPIKey: apiKey,\n\t\t\tModel: resolved,\n\t\t\tBaseURL: baseURL,\n\t\t\t// Thinking matches the API default. Individual requests still opt\n\t\t\t// out through ChatOptions.DisableReasoning.\n\t\t\tThinking: true,\n\t\t\tReasoningEffort: DeepSeekDefaultEffort,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn client, nil\n\tcase IsAnthropicFamilyProvider(normalized):\n\t\tclient, err := NewAnthropicProviderClient(normalized, baseURL, resolved, apiKey)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn client, nil\n\tdefault:\n\t\t// Everything else currently rides the plain OpenAI-compatible dialect.\n\t\t// That covers 27 of the catalog's 40 providers; google and the\n\t\t// cloud-credential families still need their own dialects before they\n\t\t// will work.\n\t\tclient, err := NewOpenAICompatibleClient(OpenAICompatibleOptions{\n\t\t\tBaseURL: baseURL,\n\t\t\tModel: resolved,\n\t\t\tAPIKey: apiKey,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn client, nil\n\t}\n}\n\n// EstimateDeepSeekCostUSD returns the estimated turn cost from a token receipt.\n// cachedPromptTokens is the cache-hit portion of promptTokens; pass 0 when the\n// provider did not report one. The result is an estimate: it does not know\n// whether the request landed in a peak-pricing window.\nfunc EstimateDeepSeekCostUSD(promptTokens, cachedPromptTokens, completionTokens int) float64 {\n\tif promptTokens < 0 {\n\t\tpromptTokens = 0\n\t}\n\tif completionTokens < 0 {\n\t\tcompletionTokens = 0\n\t}\n\tif cachedPromptTokens < 0 {\n\t\tcachedPromptTokens = 0\n\t}\n\tif cachedPromptTokens > promptTokens {\n\t\tcachedPromptTokens = promptTokens\n\t}\n\tmissTokens := promptTokens - cachedPromptTokens\n\tconst perMillion = 1_000_000.0\n\treturn float64(cachedPromptTokens)/perMillion*DeepSeekInputCacheHitUSDPerMTok +\n\t\tfloat64(missTokens)/perMillion*DeepSeekInputCacheMissUSDPerMTok +\n\t\tfloat64(completionTokens)/perMillion*DeepSeekOutputUSDPerMTok\n}\n"}}, "internal/catalog/catalog_test.go": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068299.4998608, "n": 4945, "h": "e49149b34bfc7de9", "c": "package catalog\n\nimport (\n\t\"math\"\n\t\"testing\"\n)\n\n// A broken embedded snapshot must be loud. Every other test here would pass\n// vacuously against an empty catalog, so this one guards the rest.\nfunc TestEmbeddedSnapshotLoads(t *testing.T) {\n\tif err := Err(); err != nil {\n\t\tt.Fatalf(\"embedded snapshot is unusable: %v\", err)\n\t}\n\tproviders := Providers()\n\tif len(providers) < 20 {\n\t\tt.Fatalf(\"snapshot has only %d providers; it looks truncated\", len(providers))\n\t}\n\tmodels := 0\n\tfor _, provider := range providers {\n\t\tmodels += len(provider.Models)\n\t}\n\tif models < 500 {\n\t\tt.Fatalf(\"snapshot has only %d models; it looks truncated\", models)\n\t}\n}\n\nfunc TestProviderIDsAreOrderedAndUnique(t *testing.T) {\n\tids := ProviderIDs()\n\tseen := make(map[ProviderID]struct{}, len(ids))\n\tfor i, id := range ids {\n\t\tif _, duplicate := seen[id]; duplicate {\n\t\t\tt.Fatalf(\"duplicate provider id %q\", id)\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tif i > 0 && ids[i-1] > id {\n\t\t\tt.Fatalf(\"provider ids are not ordered: %q before %q\", ids[i-1], id)\n\t\t}\n\t}\n}\n\n// These are the numbers sonar previously hard-coded from DeepSeek's published\n// contract. Pinning them here proves the catalog is a faithful replacement for\n// those constants rather than a plausible-looking substitute.\nfunc TestDeepSeekFlashMetadataMatchesPublishedContract(t *testing.T) {\n\tmodel, ok := LookupModel(\"deepseek\", \"deepseek-v4-flash\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek-v4-flash is absent from the catalog\")\n\t}\n\tfor _, check := range []struct {\n\t\tfield string\n\t\tgot float64\n\t\twant float64\n\t}{\n\t\t{\"cost_per_1m_in\", model.CostPer1MIn, 0.14},\n\t\t{\"cost_per_1m_out\", model.CostPer1MOut, 0.28},\n\t\t{\"cost_per_1m_in_cached\", model.CostPer1MInCached, 0.0028},\n\t\t{\"context_window\", float64(model.ContextWindow), 1_000_000},\n\t\t{\"default_max_tokens\", float64(model.DefaultMaxTokens), 384_000},\n\t} {\n\t\tif math.Abs(check.got-check.want) > 1e-9 {\n\t\t\tt.Errorf(\"%s = %v, want %v\", check.field, check.got, check.want)\n\t\t}\n\t}\n\tif !model.CanReason {\n\t\tt.Error(\"can_reason = false, want true\")\n\t}\n\tif model.DefaultReasoningEffort != \"high\" {\n\t\tt.Errorf(\"default_reasoning_effort = %q, want high\", model.DefaultReasoningEffort)\n\t}\n\t// DeepSeek V4 takes no images. sonar still exposes attachment UI, so this\n\t// assertion is the hook for gating it.\n\tif model.SupportsImages {\n\t\tt.Error(\"supports_attachments = true; the attachment gate assumes otherwise\")\n\t}\n}\n\nfunc TestAPIKeyEnvReturnsNamesNotValues(t *testing.T) {\n\tif got := APIKeyEnv(\"deepseek\"); got != \"DEEPSEEK_API_KEY\" {\n\t\tt.Errorf(\"deepseek key env = %q, want DEEPSEEK_API_KEY\", got)\n\t}\n\tif got := APIKeyEnv(\"anthropic\"); got != \"ANTHROPIC_API_KEY\" {\n\t\tt.Errorf(\"anthropic key env = %q, want ANTHROPIC_API_KEY\", got)\n\t}\n\t// Cloud credential chains have no single variable; an empty result is the\n\t// correct answer, not a lookup failure to paper over.\n\tif got := APIKeyEnv(\"bedrock\"); got != \"\" {\n\t\tt.Errorf(\"bedrock key env = %q, want empty\", got)\n\t}\n\tif got := APIKeyEnv(\"not-a-provider\"); got != \"\" {\n\t\tt.Errorf(\"unknown provider key env = %q, want empty\", got)\n\t}\n}\n\nfunc TestLookupMisses(t *testing.T) {\n\tif _, ok := LookupProvider(\"not-a-provider\"); ok {\n\t\tt.Error(\"unknown provider was found\")\n\t}\n\tif _, ok := LookupModel(\"deepseek\", \"not-a-model\"); ok {\n\t\tt.Error(\"unknown model was found\")\n\t}\n\tif models := Models(\"not-a-provider\"); models != nil {\n\t\tt.Errorf(\"unknown provider returned %d models\", len(models))\n\t}\n}\n\n// Callers must not be able to corrupt the shared catalog through a returned\n// slice, or one surface's filtering would silently reshape another's.\nfunc TestReturnedSlicesAreCopies(t *testing.T) {\n\tfirst := Models(\"deepseek\")\n\tif len(first) == 0 {\n\t\tt.Fatal(\"deepseek has no models\")\n\t}\n\toriginal := first[0].ID\n\tfirst[0].ID = \"mutated\"\n\tif second := Models(\"deepseek\"); second[0].ID != original {\n\t\tt.Fatalf(\"mutating a returned model leaked into the catalog: %q\", second[0].ID)\n\t}\n\n\tids := ProviderIDs()\n\tids[0] = \"mutated\"\n\tif ProviderIDs()[0] == \"mutated\" {\n\t\tt.Fatal(\"mutating returned ids leaked into the catalog\")\n\t}\n}\n\nfunc TestEstimateCostUSD(t *testing.T) {\n\tmodel, ok := LookupModel(\"deepseek\", \"deepseek-v4-flash\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek-v4-flash is absent from the catalog\")\n\t}\n\n\tmiss := EstimateCostUSD(model, 1_000_000, 0, 0)\n\thit := EstimateCostUSD(model, 1_000_000, 1_000_000, 0)\n\tif miss <= hit {\n\t\tt.Fatalf(\"a cache miss (%f) must cost more than a hit (%f)\", miss, hit)\n\t}\n\tif math.Abs(miss-0.14) > 1e-9 {\n\t\tt.Errorf(\"1M miss tokens = %f, want 0.14\", miss)\n\t}\n\tif math.Abs(EstimateCostUSD(model, 0, 0, 1_000_000)-0.28) > 1e-9 {\n\t\tt.Error(\"1M output tokens did not price at 0.28\")\n\t}\n\t// An over-reported cache count must clamp rather than credit the caller.\n\tif got := EstimateCostUSD(model, 10, 999, 0); got < 0 {\n\t\tt.Errorf(\"over-reported cache hits produced a negative cost: %f\", got)\n\t}\n\tif got := EstimateCostUSD(model, -5, -5, -5); got != 0 {\n\t\tt.Errorf(\"negative token counts produced %f, want 0\", got)\n\t}\n}\n"}}, "config.example.yaml": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068426.2955647, "n": 20808, "h": "8dbfb12ce3abdead", "c": "# sonar configuration\n# Place at ./sonar.yaml, $XDG_CONFIG_HOME/sonar/config.yaml,\n# or the fallback $HOME/.config/sonar/config.yaml (in precedence order).\n\n# Inference. sonar runs hosted models; deepseek-v4-flash is the DEFAULT, not the\n# only option. Endpoint, credential variable, default model, and context window\n# all come from the embedded provider catalog, so naming a provider is enough:\n#\n# provider: { type: groq } # or cerebras, moonshot, fireworks, ...\n#\n# NOTE: only the OpenAI-compatible dialect exists today. That covers 27 of the\n# catalog's 40 providers; anthropic, google, azure, bedrock, and vertex need\n# their own dialects and will not work yet.\n#\n# The whole block below is optional — the defaults already resolve to:\n#\n# type: deepseek\n# base_url: https://api.deepseek.com\n# model: deepseek-v4-flash (1M context, 384K max output)\n# api_key_env: DEEPSEEK_API_KEY\n#\n# Secrets are NEVER written here — only environment variable NAMES. The value is\n# read from the process environment at launch:\n#\n# export DEEPSEEK_API_KEY=...\n# # or, with a secret manager:\n# tvault set -p sonar DEEPSEEK_API_KEY\n# tvault run -p sonar --only DEEPSEEK_API_KEY -- sonar\n#\n# provider:\n# type: deepseek\n# # Point at a gateway or proxy that speaks the same dialect:\n# # base_url: https://my-gateway.example.com/deepseek\n#\n# Chain-of-thought is ON by default, matching the API. It is the dominant cost\n# term on long turns; a request opts out per-call, not from config.\n#\n# Cost, per 1M tokens (regular rate): $0.14 input on a cache miss, $0.0028 on a\n# cache hit, $0.28 output. A cache hit is ~50x cheaper than a miss, so a stable\n# prompt prefix is the single biggest lever you have. DeepSeek has announced\n# peak-hour pricing at 2x every billing item (09:00-12:00 and 14:00-18:00\n# Beijing time), so treat any in-app cost figure as an estimate.\n\n# Optional: read provider API keys from a KEY=VALUE file instead of exporting\n# them by hand. Only names the catalog recognises as credential variables are\n# applied, and never over something already exported — a shared secrets file\n# commonly also defines PATH or EDITOR, and applying those would corrupt the\n# process. Override with SONAR_ENV_FILE.\n#\n# credentials:\n# env_file: ~/.config/sonar/env\n\n# `provider: {type: ollama}` means Ollama Cloud (https://ollama.com/v1) with\n# OLLAMA_API_KEY — an ordinary hosted OpenAI-compatible provider, like groq or\n# cerebras. sonar has no local runtime and does not talk to a daemon on this\n# machine.\n#\n# The block below is NOT that. It configures a local Ollama daemon for the ICE\n# embeddings backend only, which is off by default because no hosted provider\n# sonar supports serves an embeddings endpoint. It has no effect on chat.\nollama:\n model: \"qwen3.5:2b\"\n base_url: \"http://localhost:11434\"\n num_ctx: 16384\n\n# local_only bounds TOOL endpoints (MCP), not inference. sonar always reaches\n# DeepSeek over the network, so leaving this true does not keep prompts on the\n# machine — it only refuses remote MCP servers. Shell tools are bounded by the\n# sandbox below rather than by this setting.\n# Presentation only: no effect on authority, tool policy, or what leaves the\n# machine. Every scheme is contrast-checked for light and dark terminals.\n# Pick interactively with /theme; that choice is stored in\n# ~/.config/sonar/runtime-preferences.json and outranks this key.\nui:\n theme: nord # nord | catppuccin | dracula | everforest | gruvbox | kanagawa | one | rose-pine | solarized | tokyo-night\n # How wide conversational text may wrap, in columns. Code fences, diffs,\n # tables and inspectors always use the full pane and ignore this.\n #\n # Omitted, prose follows the terminal: it uses the width you actually have,\n # and shares a right edge with everything else on screen. Set a number to\n # pin a fixed measure instead — some readers prefer a shorter line, and long\n # lines are genuinely harder to scan.\n # prose_width: 96\n\nprivacy:\n local_only: true\n\n# OS-level confinement for shell subprocesses: Seatbelt on macOS, bubblewrap on\n# Linux. It is a second layer under the scoped-shell catalog, never a\n# replacement for it.\n#\n# The catalog reads a command line, so it can refuse `curl` before it runs and\n# is structurally blind to what a program does once started — `go test ./...`\n# and `npm test` execute workspace code, and a workspace script can open a\n# socket or read a file no argv ever named. The sandbox cannot read intent, but\n# it binds what a process actually touches. Neither one subsumes the other.\n#\n# What it enforces: reads of the host secret paths are denied, writes are denied\n# outside the workspace and the toolchain caches, and network access is denied.\n# What it does not: the workspace stays writable, so a confined `rm -rf .` still\n# destroys uncommitted work. Approvals remain the only thing that stops that.\n#\n# Off by default because it changes which commands SUCCEED, not only which are\n# allowed to start — with the network denied, a build that needs to download a\n# module fails. Turn it on deliberately rather than discover it.\n# Turning this on also WIDENS what AUTO runs unattended, and that is the point.\n# Most of the scoped-shell catalog's refusals exist to prove containment from a\n# command line alone — an uncatalogued binary, `node -e`, a search that walks a\n# directory. Once the kernel proves containment for every command, those\n# refusals have nothing left to prove and stop asking.\n#\n# What keeps asking is what confinement does not cover: the workspace stays\n# writable, so `rm -rf .` still destroys uncommitted work, and `git push\n# --force` still rewrites history no filesystem boundary reaches. Those are\n# decisions a human owns either way.\nsandbox:\n enabled: false\n # Confined commands reach the network. Leave false: sonar's own provider\n # calls are made by the sonar process, never by a confined child, so denying\n # this costs inference nothing.\n #\n # While it is false, commands that cannot succeed WITHOUT the network keep\n # asking rather than being widened — `npm install`, `pip install`,\n # `go mod download`, `curl`. Running them unattended into a denial the same\n # setting causes would trade a useful question for a guaranteed failure the\n # person who could have answered it never sees. Set it true and they widen\n # with everything else.\n allow_network: false\n\n# Read the transcript aloud. macOS only for now (`say`); a host without a\n# synthesizer says so at startup instead of staying quiet and looking broken.\n#\n# What you hear is a PROJECTION, not the transcript. A path read aloud is noise:\n# the same sentence measures 12.9 seconds spoken raw and 6.1 projected, and the\n# whole difference is a URL and a file path being spelled out one character at a\n# time. So paths collapse to their filename, links become \"a link\", code fences\n# are never spoken at all, and the screen keeps the complete record.\n#\n# Three channels, because they are three different things to hear. An AUTO turn\n# produces eight tool receipts and several reasoning blocks; spoken together\n# they bury the one sentence worth waiting for.\n#\n# Any keypress cancels speech immediately and drops what was queued — someone\n# typing has stopped listening.\nvoice:\n enabled: false\n # The assistant's reply, spoken sentence by sentence as it streams.\n answer: true\n # Thinking blocks, spoken once settled rather than streamed. Off by default:\n # reasoning contradicts itself, abandons paths, and runs far longer than the\n # answer, so streaming it alongside produces two voices arguing.\n reasoning: false\n # What the harness is doing — \"reading\", \"running tests\" — never the path it\n # is doing it to, and never repeated for a run of the same thing.\n activity: false\n # `say -v ?` lists this machine's voices. Empty uses the system default, and\n # this setting applies to every language.\n # voice: Samantha\n #\n # A voice per language, which is usually what you want instead. A voice is\n # built for one language and mangles the others — an English voice reading\n # \"encontré el problema\" lands somewhere between an accent and a different\n # sentence — so the harness detects which language an answer is in and picks\n # from here.\n #\n # A language with no entry gets one chosen from what the host has installed,\n # preferring your own region: that guess is what this map exists to correct.\n # The host's own language is deliberately left to the system default rather\n # than guessed at, because you already chose that one in system settings.\n # voices:\n # es: Paulina # Mexican Spanish; Mónica is Castilian\n # en: Samantha\n #\n # Words per minute. Empty uses 210, faster than the system default, because a\n # coding session is listened to while working rather than attentively.\n # rate: 210\n\n # Dictation: alt+v opens the microphone, alt+v again closes it and the\n # transcription lands in the composer. /voice does the same thing and needs no\n # terminal configuration — on a stock macOS terminal Option composes a\n # character rather than sending Alt, so alt+v never arrives at all until the\n # terminal is told otherwise (\"Use Option as Meta key\", or Ghostty's\n # macos-option-as-alt = true), and a leader key can claim it even then.\n #\n # What arrives is a draft you read before sending: dictation mis-hears, and\n # nobody should act on words no one said.\n #\n # Toggle rather than hold-to-talk because terminals report key RELEASE only\n # under the Kitty protocol; a hold binding would work in Ghostty and do\n # nothing in Terminal.app.\n #\n # Local only, and deliberately: a hosted transcriber is faster and nearly\n # free, but audio carries who is in the room and what else was said, which is\n # a different decision from sending text. Needs two tools, and pressing alt+v\n # without them names which one is missing:\n #\n # brew install ffmpeg whisper-cpp\n #\n # That is NOT enough on its own. Homebrew's whisper-cpp ships one 575 KB test\n # fixture and no usable model, so both commands succeed and dictation still\n # cannot run. Download one:\n #\n # mkdir -p ~/.cache/whisper.cpp && curl -L -o ~/.cache/whisper.cpp/ggml-base.bin \\\n # https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin\n #\n # `/voice status` reports every part of the pipeline and the command that\n # fixes whichever one is missing.\n #\n # There is no enable flag — the key does nothing until pressed.\n input:\n # A whisper.cpp ggml model path. Empty searches the usual install locations\n # and prefers the smaller models: on a laptop the difference between base\n # and large is seconds of waiting per utterance.\n # model: ~/.cache/whisper.cpp/ggml-base.bin\n # ISO code. Empty lets Whisper detect it, which costs a little latency and\n # is the right default if you switch languages.\n # language: es\n\n# Provider-loop budgets are independent so interactive turns stay concise while\n# AUTO has room to inspect, implement, and verify without a premature stop.\ntools:\n timeout: 30s\n # Bounds a single MCP tool call. The 60s default suits interactive tools, but\n # a server that indexes a codebase first — vecgrep embeds through local\n # Ollama, codemap builds a symbol graph — legitimately runs for minutes on a\n # large repo.\n #\n # This matters more than it looks. A timeout is indistinguishable from a hung\n # server, so the call is \"unanswered\"; an unanswered call whose effect class\n # is unknown becomes an outcome_unknown, which halts the turn and demands a\n # manual /recover. Two settings prevent that together:\n # 1. this headroom, and\n # 2. an exact read_only trust route for the tool (see servers[].trust\n # below), which both auto-authorizes it and makes a timeout a plain\n # failure the loop can continue past.\n #\n # For vecgrep specifically the real cure is operational: run its background\n # daemon (`vecgrep daemon start`), which owns the index, watches files, and\n # throttles Ollama, so calls answer from a warm index instead of building one\n # inside the call.\n # mcp_timeout: 5m\n max_grep_results: 500\n max_iterations: 10\n # auto_max_iterations bounds ONE provider segment. When it fires, AUTO\n # checkpoints and starts another segment, so the work an AUTO turn can reach\n # is iterations x segments — bounded by the two ceilings below.\n auto_max_iterations: 40\n # Whole-turn AUTO ceilings. These are what make an unattended job of several\n # hours possible; raising auto_max_iterations alone cannot, because the\n # whole-turn ceiling applies on top of it.\n #\n # Defaults are 8 segments and 90m, so leaving these unset behaves exactly as\n # before. Raise them for long unattended work, and remember that a metered\n # hosted provider bills for every one of those segments.\n #\n # Neither weakens the stall guard: a segment that repeats the previous work\n # set with no verified effects still ends the run, whatever the ceiling.\n # auto_max_segments: 40\n # auto_max_wall_time: 6h\n #\n # How long a tool call waits for a human approval before the host refuses it\n # and the run continues with the next call. Omitted means wait indefinitely,\n # which is right when someone is watching.\n #\n # Set it for unattended runs. Otherwise the first approval consumes the whole\n # remaining wall budget in front of a modal nobody will answer, and the run\n # ends having done nothing. A timeout can only withhold permission, never\n # grant it: the call does not run, the refusal is recorded, and the model is\n # told to find another route.\n # approval_timeout: 2m\n\n# Typed continuation actions are suggestions by default. auto_read_only may\n# follow only exact, fully specified, unblocked, registry-validated read-only\n# contracts; it never auto-runs shell, mutation, secreted execution, or a\n# generic gateway proxy. Every call still passes normal host trust, approval,\n# and execution-ledger checks. The runtime hard ceiling is two automatic steps.\ncontinuations:\n mode: suggest # off | suggest | auto_read_only\n max_auto_steps: 2\n\n# Auto-routing uses installed, non-exclusive compact tiers only. Qwen 9B,\n# Ornith 9B, and Gemma E2B remain available for an explicit deep-task profile.\nmodel:\n models:\n - name: \"qwen3.5:0.8b\" # trivial: routing/classification only — weak at tools\n family: \"qwen3.5\"\n size: \"0.8B\"\n capability: \"simple\"\n - name: \"qwen3.5:2b\" # DEFAULT: smallest model with usable tool calling\n family: \"qwen3.5\"\n size: \"2B\"\n capability: \"medium\"\n default: true\n - name: \"qwen3.5:4b\" # largest memory-safe tier; reliable multi-step tools\n family: \"qwen3.5\"\n size: \"4B\"\n capability: \"complex\"\n - name: \"phi4-mini:latest\"\n family: \"phi\"\n size: \"2.5GB\"\n capability: \"medium\"\n exclusive: true # manual-only until behavioral tool verification\n - name: \"qwen3.5:9b\"\n family: \"qwen3.5\"\n size: \"6.6GB\"\n capability: \"advanced\"\n exclusive: true\n - name: \"ornith:latest\" # Qwen 3.5-derived 9B coding model; observed Q4 weights\n family: \"qwen3.5\"\n size: \"5.6GB Q4\"\n parameters: \"9 billion\"\n context_size: 262144 # model max; runtime allocation remains ollama.num_ctx\n capability: \"advanced\"\n exclusive: true\n - name: \"gemma4:e2b\"\n family: \"gemma\"\n size: \"7.2GB\"\n capability: \"medium\"\n exclusive: true\n default_model: \"qwen3.5:2b\"\n fallback_chain: [\"qwen3.5:2b\", \"qwen3.5:0.8b\", \"qwen3.5:4b\"]\n auto_select: true\n embed_model: \"nomic-embed-text\"\n\n# Read-only application-level expert consultation. Team, Swarm, and MoE use\n# ordinary tool-free Ollama calls; they are not a model-internal/token-level MoE.\n# Zero concurrency and fan-out values mean auto. Explicit values are caps only:\n# they can reduce work but never force the machine above the measured CPU/RAM\n# plan. The runtime serializes work when the accepted model set fits, or reduces\n# deterministic fan-out when the complete local weight set does not fit.\nexperts:\n enabled: true\n max_concurrent_inference: 0\n max_concurrent_distinct_models: 0\n max_team_experts: 0\n max_swarm_workers: 0\n max_moe_experts: 0\n max_eval_tokens: 768\n timeout: 90s\n\n# ICE (Infinite Context Engine) is DISABLED by default in sonar — see below.\n# Embeddings are computed locally via Ollama (nomic-embed-text); no data leaves\n# the machine. An empty store_path uses the managed\n# ~/.config/sonar/conversations.json default. A non-empty relative value\n# is rooted under ~/.config/sonar/ice/<workspace-hash>; absolute paths\n# and `..` escapes are rejected so repository config cannot write elsewhere or\n# place private embeddings inside the Git worktree.\nice:\n # OFF by default in sonar. ICE needs an embeddings endpoint and DeepSeek's API\n # does not expose one, so enabling it requires a separate local embedding\n # backend (Ollama with embed_model below) — which reintroduces a non-API\n # dependency the harness otherwise does not have.\n # enabled: false\n embed_model: \"nomic-embed-text\"\n # store_path: \"conversations.json\"\n\n# MCP tool servers. Prefer placing executable STDIO entries in user-wide\n# configuration. A repository-local copy of this file must be trusted with the\n# exact SONAR_TRUST_REPO_MCP digest printed at startup before any STDIO\n# process is spawned; changing the resolved executable or its content, command,\n# args, env, exact trust routes, or repository path changes that digest.\nservers:\n # Recommended: one lazy MCPHub gateway. It keeps specialist schemas out of\n # small-model context until needed and lets Cortex orchestrate the stack.\n - name: mcphub\n command: mcphub\n args: [mcp, serve, --agent, sonar]\n # Host-owned exact authority. This is honored only for local STDIO when\n # local_owner exactly matches the executable basename; wrappers, remote\n # transports, MCP annotations, wildcards, and suffix matches cannot widen it.\n # read_only routes are auto-authorized and ledgered as reads.\n # workspace_effectful routes are auto-authorized only in AUTO and only when\n # an explicit `workspace` argument resolves inside the active workspace.\n trust:\n local_owner: mcphub\n gateway: mcphub\n read_only:\n - bob__bob_check\n - bob__bob_context\n - bob__bob_inspect\n - bob__bob_path\n - bob__bob_plan\n - bob__bob_playbook\n - bob__bob_recipe_describe\n - bob__bob_stats\n - bob__bob_validate_manifest\n - cortex__cortex_handoff\n - cortex__cortex_list_tasks\n - cortex__cortex_metrics\n - cortex__cortex_overview\n - cortex__cortex_read_artifact\n - cortex__cortex_read_evidence\n - cortex__cortex_recall_cases\n - cortex__cortex_sessions\n - cortex__cortex_status\n - cortex__cortex_timeline\n - mcphub_describe_tool\n - mcphub_get_result\n - mcphub_list_servers\n - mcphub_resolve_tool\n - mcphub_search_tools\n - mcphub_stats\n workspace_effectful:\n - cortex__cortex_begin_change\n - cortex__cortex_investigate\n - cortex__cortex_note\n - cortex__cortex_open_task\n - cortex__cortex_plan\n - cortex__cortex_remember\n - cortex__cortex_request_decision\n - cortex__cortex_resolve\n - cortex__cortex_start_task\n - cortex__cortex_verify\n\n # Recommended MCPHub agent (mcphub.yaml) for this harness:\n # agents.sonar.mode: gateway\n # pin: []\n # tool_schema_budget: \"0\"\n # servers: bob, cortex, codemap, vecgrep, hitspec, hitspec_web,\n # fcheap, monitor, obsidian, minerva, glyph, cairntrace\n # Enrich each server's use_when / tags so mcphub_resolve_tool ranks well.\n # sonar host enrichment: resolve → optional host describe pre-fetch →\n # phase playbook → model mcphub_call_tool under normal approval.\n\n # Omitting trust currently uses a build-owned compatibility profile only\n # for exact mcphub/cortex/bob basenames. Prefer an explicit list. To suppress\n # that migration bridge, configure only: trust: {disabled: true}\n\n # Direct STDIO transport is also supported when MCPHub is not installed.\n # - name: noted\n # command: noted\n # args: [mcp]\n\n # tinyvault — requires `tvault init` before first use\n # - name: tinyvault\n # command: tvault\n # args: [mcp-server]\n\n # Docker MCP Gateway (requires Docker Desktop with MCP enabled)\n # - name: docker-gateway\n # command: docker\n # args: [\"mcp\", \"gateway\", \"run\"]\n\n # SSE transport\n # - name: remote-server\n # transport: sse\n # url: \"http://localhost:8811\"\n\n # Streamable HTTP transport\n # - name: streamable-server\n # transport: streamable-http\n # url: \"http://localhost:8812/mcp\"\n"}}, "specs/tui_help.yml": {"4097f5d1d910282f3509dae042796e127011c927": {"t": 1786068430.4441266, "n": 2700, "h": "3bbd98a47fc207b0", "c": "version: 1\ncontractHash: sha256:90815d24e4cb8ed5dc9f2c5504b50de8c152c3083f581af5bf61f3d0cb644b87\nname: sonar_tui_help\nintent: |\n a user can open the sonar TUI help overlay, see shortcut and slash-command guidance, close it, and quit.\ntarget:\n # Driven through the fake DeepSeek fixture. Pointing at the real\n # binary made this spec depend on an ambient DEEPSEEK_API_KEY: it\n # passed where one was exported and exited 1 in CI, and even with a\n # placeholder it pinged api.deepseek.com for a 401. See\n # specs/fixtures/fake_deepseek.\n cmd:\n - \"${projectRoot}/.glyphrun/tmp/fake-deepseek\"\n - \"${projectRoot}/bin/sonar\"\n - \"${projectRoot}/.glyphrun/tmp/help-protocol.txt\"\n cwd: \".\"\n env:\n HOME: \"${projectRoot}/.glyphrun/tmp/help-home\"\n SONAR_AGENTS_DIR: \"${projectRoot}/.glyphrun/tmp/help-agents\"\n OLLAMA_HOST: \"http://127.0.0.1:1\"\npreconditions:\n commands:\n - run: \"rm -rf .glyphrun/tmp/help-protocol.txt .glyphrun/tmp/help-home .glyphrun/tmp/help-agents && mkdir -p .glyphrun/tmp/help-home/.config/sonar .glyphrun/tmp/help-agents && printf '%s\\\\n' 'model:' ' auto_select: false' 'ice:' ' enabled: false' > .glyphrun/tmp/help-home/.config/sonar/config.yaml && go build -o ./bin/sonar ./cmd/sonar && go build -o .glyphrun/tmp/fake-deepseek ./specs/fixtures/fake_deepseek\"\n cwd: \".\"\n timeoutMs: 30000\nsteps:\n - wait:\n screen:\n contains: \"enter send\"\n timeoutMs: 5000\n - wait:\n screen:\n contains: \"remote prompts\"\n timeoutMs: 5000\n # Glyph v0.14.3 does not yet name F1; ESC OP is its terminal sequence.\n - send:\n bytes: \"\\\\x1bOP\"\n - wait:\n screen:\n contains: \"Keyboard Shortcuts\"\n timeoutMs: 3000\n - snapshot: help\n - press: \"pgdown\"\n - wait:\n screen:\n contains: \"Slash Commands\"\n timeoutMs: 3000\n - snapshot: help_commands\n - press: \"q\"\n - wait:\n screen:\n contains: \"enter send\"\n timeoutMs: 3000\n - press: \"ctrl+c\"\n - wait:\n process:\n exitCode: 0\n timeoutMs: 3000\noutcomes:\n - id: help_snapshot_stable\n description: the help overlay content matches the committed terminal snapshot\n verify:\n snapshot:\n name: help\n mode: text\n - id: slash_commands_snapshot_stable\n description: scrolling help reveals slash-command guidance\n verify:\n snapshot:\n name: help_commands\n mode: text\n - id: ctrl_c_quits\n description: ctrl+c exits the TUI cleanly after help is closed\n verify:\n process:\n exitCode: 0\n"}}}}