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.
1 line
51 KiB
JSON
1 line
51 KiB
JSON
{"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"}}}} |