Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 352cab4a94 | |||
| 58f4001917 | |||
| 6c5e969217 | |||
| 74bd570438 | |||
| d38d7daf25 | |||
| 06d4069076 | |||
| f641bd4971 | |||
| 798f2ab3c3 | |||
| 9814795b3c | |||
| 047924da2b | |||
| a23eb6b92c | |||
| 0981fb82d6 | |||
| 3888966e68 | |||
| 847cd5fe0c | |||
| 001865f069 | |||
| c1c52f139d | |||
| 7040041f13 | |||
| 1828151162 | |||
| b5062d59e9 | |||
| b13a6a2801 |
+13
-2
@@ -1,4 +1,15 @@
|
||||
MISTRAL_API_KEY="asd**"
|
||||
ANTHROPICS_API_KEY="sk-ant-**"
|
||||
# --- LLM provider keys (set at least one) ---
|
||||
ANTHROPIC_API_KEY="sk-ant-**"
|
||||
OPENAI_API_KEY="sk-proj-**"
|
||||
GEMINI_API_KEY="AIza**"
|
||||
# Alternative to GEMINI_API_KEY (either is accepted)
|
||||
# GOOGLE_API_KEY="AIza**"
|
||||
MISTRAL_API_KEY="**"
|
||||
|
||||
# --- Optional overrides (config can also set these) ---
|
||||
# GNOMA_PROVIDER="anthropic"
|
||||
# GNOMA_MODEL="claude-sonnet-4-6"
|
||||
|
||||
# --- Subprocess sandbox bypass (footguns — set deliberately) ---
|
||||
# GNOMA_AGY_BYPASS_PERMISSIONS=1
|
||||
# GNOMA_CODEX_BYPASS_SANDBOX=1
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Release workflow — runs when a vX.Y.Z tag is pushed (including mirror
|
||||
# pushes from somegit.dev). Drives GoReleaser to publish:
|
||||
# - static binaries (linux/darwin/windows × amd64/arm64) + checksums
|
||||
# + autogenerated changelog to the GitHub releases page
|
||||
# - multi-arch container images to ghcr.io/vikingowl91/gnoma
|
||||
#
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions and already
|
||||
# carries packages:write thanks to the permissions block, so no PAT is
|
||||
# needed for either the release upload or the ghcr.io push.
|
||||
#
|
||||
# Security note: this workflow does not interpolate any untrusted
|
||||
# context (commit messages, PR titles, issue bodies) into shell commands.
|
||||
# All ${{ ... }} references live in with: / env: blocks, which are
|
||||
# safely passed as strings rather than evaluated as shell.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Force GoReleaser to use the triggering tag rather than fall
|
||||
# back to `git describe` — which can resolve to an older tag
|
||||
# (e.g., a vX.Y.Z-rc tag) when multiple tags point at the same
|
||||
# commit. Surfaced as the v0.3.1 release failure on 2026-05-24.
|
||||
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
||||
+9
-3
@@ -37,9 +37,12 @@ changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
# Match both bare and scoped conventional commits, e.g. both
|
||||
# "docs:" and "docs(readme):" should be excluded.
|
||||
- "^docs[:(]"
|
||||
- "^test[:(]"
|
||||
- "^chore[:(]"
|
||||
- "^style[:(]"
|
||||
|
||||
# Multi-arch Docker images published to GitHub Container Registry.
|
||||
# Build host needs Docker buildx and a `docker login ghcr.io` for the
|
||||
@@ -98,3 +101,6 @@ release:
|
||||
github:
|
||||
owner: VikingOwl91
|
||||
name: gnoma
|
||||
# Auto-detect prereleases from semver: tags with -rc, -beta, -alpha,
|
||||
# -pre, etc. suffix get marked as prerelease on GitHub.
|
||||
prerelease: auto
|
||||
|
||||
@@ -5,20 +5,60 @@ Provider-agnostic agentic coding assistant in Go 1.26.
|
||||
Named after the northern pygmy-owl (Glaucidium gnoma).
|
||||
Agents are called "elfs" (elf owl).
|
||||
|
||||
## Module
|
||||
`somegit.dev/Owlibou/gnoma`
|
||||
## Module & repo layout
|
||||
- Module: `somegit.dev/Owlibou/gnoma`
|
||||
- Upstream (primary, accepts PRs): <https://somegit.dev/Owlibou/gnoma>
|
||||
- GitHub mirror (read-only): <https://github.com/VikingOwl91/gnoma>
|
||||
|
||||
PRs go to the upstream Gitea instance, not GitHub. The GitHub side is a
|
||||
push mirror — direct pushes to `main`/`dev` there will be rejected by the
|
||||
ruleset.
|
||||
|
||||
## Big picture (read this before diving in)
|
||||
|
||||
Single static Go binary. Request flow:
|
||||
|
||||
1. `cmd/gnoma` parses flags, picks TUI vs pipe mode, builds the session.
|
||||
2. `internal/session` owns one chat lifecycle; `internal/engine` runs the
|
||||
agentic loop (stream → tool calls → re-query → until done).
|
||||
3. `internal/router` picks the arm per prompt: multi-armed bandit over
|
||||
provider adapters in `internal/provider/{anthropic,openai,google,mistral,openaicompat}`,
|
||||
tiered SLM (`internal/slm`) → CLI-agent subprocess → local → cloud,
|
||||
with `Strengths` + `MaxComplexity` + `CostWeight` shaping selection.
|
||||
4. `internal/security` is the safety boundary: SafeProvider wrapping,
|
||||
firewall (network egress), secret scanner, redaction, incognito mode.
|
||||
`internal/safety` is separate — it's the pre-launch CWD classifier.
|
||||
5. `internal/tool` is the local-action boundary; `internal/permission`
|
||||
gates every tool call.
|
||||
6. Extensibility surfaces: `internal/hook`, `internal/skill`,
|
||||
`internal/mcp` (JSON-RPC over stdio), `internal/plugin` (TOFU-pinned).
|
||||
|
||||
Discriminated unions (struct + type discriminant) are the project's
|
||||
chosen way to model variants — see `internal/message` and
|
||||
`internal/stream`. Don't reach for interfaces when a discriminant fits.
|
||||
|
||||
Full essentials (vision, domain model, ADRs, process flows):
|
||||
`docs/essentials/INDEX.md`. **Read INDEX.md before changing
|
||||
architectural boundaries or adding new packages.** Note: INDEX
|
||||
predates `internal/safety` and `internal/slm` — cross-check the actual
|
||||
tree.
|
||||
|
||||
## Build & Test
|
||||
```sh
|
||||
make build # build binary to ./bin/gnoma
|
||||
make test # run all tests
|
||||
make lint # run golangci-lint
|
||||
make cover # test with coverage report
|
||||
```
|
||||
make build # ./bin/gnoma
|
||||
make test # unit tests
|
||||
make test-integration # //go:build integration — needs real API keys
|
||||
make lint # golangci-lint run ./...
|
||||
make check # fmt + vet + lint + test — canonical pre-commit gate
|
||||
make cover # coverage.html
|
||||
|
||||
## Project Essentials
|
||||
Project architecture, domain model, and design decisions: `docs/essentials/INDEX.md`
|
||||
Read INDEX.md before making architectural changes or adding new system boundaries.
|
||||
# Run a single test / package
|
||||
go test -run TestRouterSelect ./internal/router/
|
||||
go test -v ./internal/router/
|
||||
|
||||
# Benchmarks
|
||||
go test -bench=. ./internal/router/
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build run check install test lint cover clean fmt vet
|
||||
.PHONY: build run check install test lint cover clean fmt vet vuln sec
|
||||
|
||||
BINARY := gnoma
|
||||
BINDIR := ./bin
|
||||
@@ -10,7 +10,7 @@ build:
|
||||
run: build
|
||||
$(BINDIR)/$(BINARY)
|
||||
|
||||
check: fmt vet lint test
|
||||
check: fmt vet lint test vuln sec
|
||||
@echo "All checks passed!"
|
||||
|
||||
install:
|
||||
@@ -43,3 +43,13 @@ clean:
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# Reachability-checked dependency vuln scan against the Go vuln DB.
|
||||
# Install: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
vuln:
|
||||
govulncheck ./...
|
||||
|
||||
# Static security analysis via Semgrep (Go ruleset + security-audit).
|
||||
# Install: pip install semgrep (or: brew install semgrep)
|
||||
sec:
|
||||
semgrep --config=p/golang --config=p/security-audit --metrics=off --error .
|
||||
|
||||
@@ -10,11 +10,65 @@ to the best available model — cloud or local — through a multi-armed bandit
|
||||
router, executes tools on your behalf, and stays extensible through hooks,
|
||||
skills, MCP servers, and plugins.
|
||||
|
||||
Named after the northern pygmy-owl (*Glaucidium gnoma*); agents are called
|
||||
**elfs** (elf owl).
|
||||

|
||||
|
||||
- **Upstream:** <https://somegit.dev/Owlibou/gnoma>
|
||||
- **GitHub mirror:** <https://github.com/VikingOwl91/gnoma>
|
||||
*Every turn shows which arm the router picked and why — here a local
|
||||
`qwen3:14b` was selected for a `generation` task.*
|
||||
|
||||
## What makes gnoma different
|
||||
|
||||
- **Multi-armed bandit router.** Per-prompt arm selection based on
|
||||
capability gates, declared `Strengths`, latency, and cost. Visible in
|
||||
the TUI on every turn — no black box.
|
||||
- **`[router].prefer = local | cloud | auto`.** Pin routing toward local
|
||||
models, cloud, or let the bandit decide. Offline-first workflows still
|
||||
reach for Claude when the local model would obviously flail.
|
||||
- **Tier-0 SLM routing.** A tiny local model classifies each prompt and
|
||||
handles trivial tasks itself, keeping the heavy provider for real work.
|
||||
- **Content boundary + secret scanner.** Every outgoing LLM message
|
||||
and incoming tool result is scanned for secrets (regex + Shannon
|
||||
entropy on long tokens), redacted or blocked at the content level.
|
||||
Paths are canonicalised (TOCTOU-safe), Unicode is sanitized
|
||||
(homoglyphs, BiDi tricks), and a `SafeProvider` boundary keeps
|
||||
incognito-mode data out of long-lived stores. *(Per-host network
|
||||
egress allowlist is on the roadmap, not in place today.)*
|
||||
- **No phone-home.** gnoma itself sends nothing off-machine — zero
|
||||
analytics endpoint, zero metrics service, no remote logging.
|
||||
Prompts of course go to whatever provider you route them to:
|
||||
cloud arms ship data to that provider by design; pair
|
||||
Ollama/llama.cpp with `--incognito` if you want everything
|
||||
on-device.
|
||||
- **Provider-agnostic from day one.** Anthropic, OpenAI, Google, Mistral,
|
||||
Ollama, llama.cpp, plus subprocess CLIs (`claude`, `codex`, `agy`,
|
||||
`vibe`). Mix cloud and local in the same session.
|
||||
- **Vision end-to-end.** `[Image: /path]` markers in prompts, `Ctrl+V`
|
||||
paste in the TUI, capability-gated per arm.
|
||||
- **Single static binary.** `CGO_ENABLED=0`, multi-arch container on
|
||||
ghcr.io. No daemon, no runtime deps.
|
||||
|
||||
## Status
|
||||
|
||||
Pre-1.0 (current: **v0.3.0**). Single maintainer, breaking changes
|
||||
possible. The provider, router, and engine surfaces are settling;
|
||||
config schema and TUI bindings may still shift between minor versions.
|
||||
Apache 2.0.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Install](#install)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Vision / image input](#vision--image-input)
|
||||
- [Providers](#providers)
|
||||
- [Config](#config)
|
||||
- [Routing defaults](#routing-defaults)
|
||||
- [SLM routing](#slm-small-language-model-routing)
|
||||
- [Session persistence](#session-persistence)
|
||||
- [Extensibility](#extensibility)
|
||||
- [Subcommands](#subcommands)
|
||||
- [Security](#security)
|
||||
- [Development](#development)
|
||||
- [About](#about)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
@@ -418,9 +472,25 @@ built-in batching skill.
|
||||
|
||||
gnoma runs tools and shell commands on your behalf. The
|
||||
[`internal/security`](internal/security) package canonicalises every path
|
||||
(TOCTOU-safe), gates network access through a configurable firewall, and
|
||||
scans tool output for secrets before it ever reaches the model. The
|
||||
`SafeProvider` boundary keeps incognito-mode data out of long-lived stores.
|
||||
(TOCTOU-safe), scans every outgoing LLM message and incoming tool result
|
||||
for secrets (regex + Shannon entropy) before it reaches the model, and
|
||||
sanitizes Unicode (homoglyphs, BiDi tricks). The `SafeProvider` boundary
|
||||
keeps incognito-mode data out of long-lived stores.
|
||||
|
||||
> **Scope note.** The current "firewall" is a content boundary — it
|
||||
> redacts/blocks secrets in inputs and outputs. It is **not** a
|
||||
> network-egress firewall: outgoing HTTP from tools and providers goes
|
||||
> through stock `http.Client`, with no per-host allowlist or
|
||||
> dial-layer enforcement. Per-host egress rules and a per-session
|
||||
> audit log of blocked/redacted events are tracked in
|
||||
> [TODO.md](TODO.md).
|
||||
>
|
||||
> **Data flow.** gnoma itself emits no telemetry to external services
|
||||
> — no analytics, no metrics endpoint, no remote logging. When you
|
||||
> route to a cloud provider (Anthropic, OpenAI, Google, Mistral),
|
||||
> prompts and tool data are sent to that provider as required to
|
||||
> fulfill the request — by design. For fully on-device operation,
|
||||
> use Ollama or llama.cpp and `--incognito`.
|
||||
|
||||
### Entropy false-positive reduction
|
||||
|
||||
@@ -498,6 +568,15 @@ Architecture, conventions, and TDD workflow: [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
Named after the northern pygmy-owl (*Glaucidium gnoma*); agents are called
|
||||
**elfs** (elf owl).
|
||||
|
||||
- **Upstream:** <https://somegit.dev/Owlibou/gnoma>
|
||||
- **GitHub mirror:** <https://github.com/VikingOwl91/gnoma> (read-only;
|
||||
PRs go to upstream Gitea)
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
@@ -4,35 +4,169 @@ Active work, newest first.
|
||||
|
||||
## In flight
|
||||
|
||||
- **Startup safety + context banner** — refuse / warn / OK tier check
|
||||
on the cwd at launch (refuse in `/etc`, `/sys`, system roots; warn
|
||||
with keypress in `$HOME`, `/tmp`, common dumping grounds; OK in
|
||||
anything inside a git repo or with a project marker). Context
|
||||
banner always shown with cwd, git state, model, modes, and a
|
||||
top-level sensitive-file inventory. Bypass via
|
||||
`--dangerously-allow-anywhere`. Complements the in-flight
|
||||
sensitive-content unified-policy work (this is the pre-flight
|
||||
layer; that is the runtime layer). See
|
||||
[`docs/superpowers/plans/2026-05-23-startup-safety-banner.md`](docs/superpowers/plans/2026-05-23-startup-safety-banner.md).
|
||||
- **Routing-preference policy** — `[router].prefer = "local" | "cloud" | "auto"`
|
||||
config knob biasing selection via a soft score multiplier
|
||||
(0.3 / 0.5 / 1.0). Preserves Strengths cross-tier promotion and
|
||||
the bandit's learning; complements rather than replaces incognito.
|
||||
Forced arms (`--provider X`) and incognito still take priority.
|
||||
Closes the original 2026-05-23 session item B (deferred when the
|
||||
defaults-refresh work landed first). See
|
||||
[`docs/superpowers/plans/2026-05-23-prefer-routing-policy.md`](docs/superpowers/plans/2026-05-23-prefer-routing-policy.md).
|
||||
- **Routing defaults refresh** — bake family-keyed `Strengths` +
|
||||
`MaxComplexity` into discovery so a freshly-pulled local fleet
|
||||
routes sensibly without any TOML config. Adds a non-chat exclude
|
||||
list (filters `embeddinggemma`, `kokoros`, `whisper-base`,
|
||||
`vibevoice`, `*-asr/-tts/-audio/-reranker`), extends
|
||||
`knownVisionModelPrefixes` (gemma4, glm-ocr), and refreshes the
|
||||
cloud-side registry (Gemini 3.x, `gpt-5.3-codex`). Closed-model
|
||||
`Strengths` + `CostWeight` defaults land in the provider modules.
|
||||
Driven by benchmark snapshot 2026-05-23
|
||||
(artificialanalysis.ai v4.0, llm-stats.com). See
|
||||
[`docs/superpowers/plans/2026-05-23-routing-defaults-refresh.md`](docs/superpowers/plans/2026-05-23-routing-defaults-refresh.md).
|
||||
- **Config write/merge — silent corruption of layered configs.**
|
||||
`internal/config/write.go:setConfig` reads the existing TOML into a
|
||||
zero-valued `Config` struct, sets one field, and writes the entire
|
||||
struct back out — so every untouched field gets serialized at its
|
||||
Go zero value (empty strings, zero ints, `false` bools). On the
|
||||
next load, those explicit zeros overwrite higher-priority layers
|
||||
via `toml.Decode`'s "present field beats absent field" semantics.
|
||||
|
||||
Concrete symptom (2026-05-24): user's `~/.config/gnoma/config.toml`
|
||||
had `[router].prefer = "cloud"` but the project-level
|
||||
`.gnoma/config.toml` had `prefer = ""` (generated by an earlier
|
||||
`gnoma config set ...` call), which silently downgraded the
|
||||
effective policy to `auto` — visible only via the new `/router`
|
||||
TUI command, with no warning.
|
||||
|
||||
Same root cause is responsible for the zero-spammed global config
|
||||
the same user has (`max_tokens = 0`, `permission.mode = ""`,
|
||||
`bash_timeout = 0`, etc.) — all overwriting sensible defaults.
|
||||
|
||||
**Fix surface (multi-part, plan-worthy):**
|
||||
|
||||
1. **Stop generating zero-spam.** Two options:
|
||||
- Tag struct fields with `,omitempty` so the BurntSushi encoder
|
||||
skips zero values. Caveat: conflates "unset" with "explicitly
|
||||
zero" for primitive types (a user who wants `max_keep = 0`
|
||||
loses it). Safe for strings/maps/slices where empty is never
|
||||
user-intent; lossy for numeric fields.
|
||||
- Switch to `pelletier/go-toml/v2` and use its document model
|
||||
to edit only the targeted key, preserving everything else
|
||||
byte-for-byte. Cleaner semantics, bigger refactor.
|
||||
- Hybrid: omitempty on string/map/slice fields, document-level
|
||||
edit for numerics. Fastest path that doesn't lose intent.
|
||||
|
||||
2. **`gnoma doctor` — read-only diagnostic.** Scans both global
|
||||
and project configs and reports:
|
||||
- Zero-spam fields that would silently shadow defaults or
|
||||
upstream layers.
|
||||
- Invalid enum values (e.g. `permission.mode = ""`).
|
||||
- Unknown / removed keys from older schema versions.
|
||||
- Effective-merged values (so the user sees what gnoma will
|
||||
actually use after layering). No writes. Exits non-zero on
|
||||
findings so it's CI-friendly.
|
||||
|
||||
3. **`gnoma upgrade-config` — active migration.** For each config
|
||||
file (global, profiles, project):
|
||||
- Compute the cleaned form (only fields the user actually set,
|
||||
dropping zeros that match defaults).
|
||||
- Write the original to `<path>.bak` with timestamp suffix.
|
||||
- Write the cleaned form to the original path.
|
||||
- Print a diff of what changed so the user can verify.
|
||||
|
||||
4. **Project-level auto-migration on startup.** If gnoma detects
|
||||
a zero-spammed project `.gnoma/config.toml` at launch:
|
||||
- Auto-run the upgrade (project-only, never auto-touch the
|
||||
global config).
|
||||
- Write `.gnoma/config.toml.bak-YYYY-MM-DD-HHMMSS`.
|
||||
- Surface a one-line notice in the startup safety banner:
|
||||
`config: migrated .gnoma/config.toml (see .bak)`.
|
||||
- The auto-migration is non-destructive (`.bak` preserves
|
||||
original) but still gated behind a `[config].auto_migrate`
|
||||
toggle, defaulting to `true`. Global configs require
|
||||
explicit `gnoma upgrade-config`.
|
||||
|
||||
5. **Project registry** (`~/.config/gnoma/projects.json`). Today
|
||||
there is no record of which directories gnoma has been launched
|
||||
in — items #2 and #3 can work with a filesystem scan
|
||||
(`find ~ -type d -name .gnoma`), but a registry makes them
|
||||
significantly faster and unlocks cross-project features.
|
||||
Sketch:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"path": "/home/.../my-repo",
|
||||
"first_seen": "2026-04-15T10:30:00Z",
|
||||
"last_seen": "2026-05-24T19:23:00Z",
|
||||
"session_count": 47
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Update on every successful startup (record project root,
|
||||
bump `last_seen` + increment `session_count`). Enables:
|
||||
- Fast `gnoma doctor --all-projects` without a filesystem walk.
|
||||
- Cross-project session listing (`gnoma sessions --all`
|
||||
picker; surface most-recent sessions across the registry).
|
||||
- `gnoma upgrade-config` that can migrate every known project
|
||||
in one invocation.
|
||||
- Future local-only aggregate stats (`gnoma stats`) — still
|
||||
no-phone-home, just a sum across the registry.
|
||||
|
||||
**Caveats and design constraints:**
|
||||
- The registry file becomes another silent-corruption surface
|
||||
— must use the same `omitempty` / atomic-write discipline
|
||||
as the encoder fix in #1, or it'll exhibit the same class
|
||||
of bug.
|
||||
- Stale entries (deleted projects). `gnoma doctor` should
|
||||
detect and offer to prune; do not auto-delete.
|
||||
- Privacy: this is literally a log of directories the user
|
||||
has worked in. Local-only, never sent off-machine (per the
|
||||
no-phone-home positioning), but worth a one-line note in
|
||||
the Security section of the README so users know it exists.
|
||||
- Opt-out: `[config].project_registry = false` for users who
|
||||
don't want this tracked. Default `true`.
|
||||
- Atomic writes (temp file + rename) so a crash mid-write
|
||||
doesn't corrupt the file.
|
||||
|
||||
Surfaced from the v0.3.1 launch wave (2026-05-24).
|
||||
|
||||
- **Bandit selector — design decisions deferred.** The current
|
||||
selector (`internal/router/selector.go:scoreArm`) is greedy
|
||||
quality-weighted: per-(arm × task-type) EMA scores blended 70/30
|
||||
with heuristic defaults, divided by CostWeight-adjusted cost. It
|
||||
is **not** a true multi-armed bandit — no UCB-style exploration
|
||||
bonus, no Thompson sampling. Tracked as a design question rather
|
||||
than a must-implement item because of two open dependencies:
|
||||
|
||||
1. **Whether to keep numeric EMA at all.** The 2026-05-07 roadmap
|
||||
(Phase 4) puts re-evaluating bandit learning on hold until the
|
||||
SLM-driven dispatcher is in production. Three options on the
|
||||
table: keep bandit as feedback for the SLM, retire EMA in
|
||||
favour of qualitative outcome summaries fed to the SLM, or
|
||||
split responsibilities (SLM = intent routing, bandit =
|
||||
cost/quality within a tier). See
|
||||
[`docs/superpowers/plans/2026-05-07-gnoma-roadmap.md`](docs/superpowers/plans/2026-05-07-gnoma-roadmap.md)
|
||||
§Phase 4.
|
||||
|
||||
2. **User-tunable selector knobs.** Several constants are
|
||||
hardcoded today: `qualityAlpha` (EMA smoothing, ~3-sample
|
||||
memory), the 70/30 observed/heuristic blend,
|
||||
`strengthScoreBonus` for tagged task types, and the
|
||||
`DefaultThresholds.Minimum` quality floor. Surfacing these as
|
||||
`[router.bandit]` config keys would let users tune for their
|
||||
workloads (faster alpha for shifting model performance, longer
|
||||
memory for stable fleets) without waiting for the strategic
|
||||
decision in #1.
|
||||
|
||||
Surfaced from the r/coolgithubprojects v0.3.1 launch thread
|
||||
(2026-05-24, `u/Ha_Deal_5079`).
|
||||
|
||||
- **Security boundary — egress controls + session audit log.** The
|
||||
current `Firewall` is a content boundary only (scans messages and
|
||||
tool results for secrets via regex + Shannon entropy, redacts or
|
||||
blocks, logs via `log/slog`). It does not enforce network egress —
|
||||
outgoing HTTP from tools and providers uses stock `http.Client`
|
||||
with no per-host allowlist or dial-layer interception. Two follow-
|
||||
ups surfaced from the r/SideProject v0.3.0 launch thread
|
||||
(2026-05-24, `u/Secret_Theme3192`):
|
||||
1. **Per-session audit log of blocked/redacted events** —
|
||||
grep-able file at `.gnoma/sessions/<id>/audit.jsonl` so the
|
||||
user can answer "what did the firewall do this session?" in
|
||||
one command. Today the `slog` output goes to whatever sink is
|
||||
configured, with no per-session grouping.
|
||||
2. **Per-host egress allowlist (HTTP transport layer)** — open
|
||||
design question: host-level (`allow api.openai.com, deny *`)
|
||||
vs per-tool (`bash can only hit these hosts`). Reply asked
|
||||
the commenter for their mental model; revisit when feedback
|
||||
lands. The README and v0.3.0 Reddit post phrasing oversold
|
||||
"network egress gated"; corrected in the same commit as this
|
||||
TODO entry.
|
||||
|
||||
- **Tool-router specialization (functiongemma)** — gated on telemetry,
|
||||
not committed. Phase A.2 adds did-switch-rate measurement to the
|
||||
two-stage `select_category` path; Phase A.3 (LoRA fine-tune of
|
||||
@@ -84,7 +218,13 @@ Active work, newest first.
|
||||
- **Structured output** with JSON schema validation — M12.
|
||||
- **Native agy JSON output** — switch the subprocess provider to
|
||||
`--output-format stream-json` once the agy CLI supports it,
|
||||
replacing the current prompt-augmentation fallback.
|
||||
replacing the current prompt-augmentation fallback. Until then,
|
||||
agy's `ToolUse` capability is set to `false` (see
|
||||
`internal/provider/subprocess/agent.go` agy entry) — without
|
||||
structured tool-call output, the router would otherwise dispatch
|
||||
tool-needing tasks to agy and the turn would hang on prose
|
||||
hallucinations of tool calls. Flip the capability back to `true`
|
||||
in the same change that lands stream-json parsing.
|
||||
- **SQLite session persistence** + serve mode — M10.
|
||||
- **Task learning** (pattern recognition, persistent tasks) — M11.
|
||||
- **Web UI** (`gnoma web`) — M15.
|
||||
|
||||
+18
-13
@@ -2,13 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
mrand "math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -61,17 +62,17 @@ var (
|
||||
func main() {
|
||||
var resumeFlag string
|
||||
var (
|
||||
providerName = flag.String("provider", "", "LLM provider (mistral, anthropic, openai, google, ollama, llamacpp)")
|
||||
model = flag.String("model", "", "model name (empty = provider default)")
|
||||
system = flag.String("system", "", "system prompt override (empty = built-in default)")
|
||||
apiKey = flag.String("api-key", "", "API key (or set MISTRAL_API_KEY env)")
|
||||
maxTurns = flag.Int("max-turns", 50, "max tool-calling rounds per turn")
|
||||
permMode = flag.String("permission", "auto", "permission mode (default, accept_edits, bypass, deny, plan, auto)")
|
||||
incognito = flag.Bool("incognito", false, "incognito mode — no persistence, no learning")
|
||||
profileFlag = flag.String("profile", "", "config profile to load (empty = default_profile from base config)")
|
||||
providerName = flag.String("provider", "", "LLM provider (mistral, anthropic, openai, google, ollama, llamacpp)")
|
||||
model = flag.String("model", "", "model name (empty = provider default)")
|
||||
system = flag.String("system", "", "system prompt override (empty = built-in default)")
|
||||
apiKey = flag.String("api-key", "", "API key (or set MISTRAL_API_KEY env)")
|
||||
maxTurns = flag.Int("max-turns", 50, "max tool-calling rounds per turn")
|
||||
permMode = flag.String("permission", "auto", "permission mode (default, accept_edits, bypass, deny, plan, auto)")
|
||||
incognito = flag.Bool("incognito", false, "incognito mode — no persistence, no learning")
|
||||
profileFlag = flag.String("profile", "", "config profile to load (empty = default_profile from base config)")
|
||||
allowAnywhere = flag.Bool("dangerously-allow-anywhere", false, "bypass the cwd safety classifier — only use if you know what you're doing")
|
||||
verbose = flag.Bool("verbose", false, "enable debug logging")
|
||||
version = flag.Bool("version", false, "print version and exit")
|
||||
verbose = flag.Bool("verbose", false, "enable debug logging")
|
||||
version = flag.Bool("version", false, "print version and exit")
|
||||
)
|
||||
flag.StringVar(&resumeFlag, "resume", "", "resume session by ID (omit ID to list sessions)")
|
||||
flag.StringVar(&resumeFlag, "r", "", "resume session (shorthand)")
|
||||
@@ -656,10 +657,14 @@ func main() {
|
||||
}
|
||||
permChecker := permission.NewChecker(permission.Mode(*permMode), permRules, pipePromptFn)
|
||||
|
||||
// Generate session-scoped ID for /tmp artifact directory
|
||||
// Generate session-scoped ID for /tmp artifact directory.
|
||||
// Use crypto/rand so the suffix isn't predictable even if a future
|
||||
// caller seeds math/rand deterministically (e.g., in tests).
|
||||
var randBuf [8]byte
|
||||
_, _ = rand.Read(randBuf[:])
|
||||
sessionID := fmt.Sprintf("%s-%06x",
|
||||
time.Now().Format("20060102-150405"),
|
||||
mrand.Int63()&0xffffff,
|
||||
binary.BigEndian.Uint64(randBuf[:])&0xffffff,
|
||||
)
|
||||
// Pass the firewall's incognito mode so Save no-ops while incognito
|
||||
// is active. Mode is consulted on every Save (dynamic), so TUI
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
@@ -1,5 +1,10 @@
|
||||
# Routing-Preference Policy — 2026-05-23
|
||||
|
||||
> **Status: shipped in v0.3.0.** Commit `f9094f6`. Implementation
|
||||
> diverged from the original plan (tier-shift instead of pure score
|
||||
> multiplier) — see "Implementation note" in the Approach section.
|
||||
> All P-1 through P-7 tasks complete.
|
||||
|
||||
Adds a config knob that biases routing toward local arms, toward
|
||||
cloud arms, or leaves the current tier+score behavior unchanged.
|
||||
Originally surfaced as item B in the 2026-05-23 routing redesign
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Routing Defaults Refresh — 2026-05-23
|
||||
|
||||
> **Status: shipped in v0.3.0.** Commits `a79e991` (scaffold) →
|
||||
> `9bb775a` (full local family table) → `2f8d4c4` (cloud defaults
|
||||
> + gpt-5.3-codex) → `c99b2c6` (README). All R-1 through R-8
|
||||
> tasks complete.
|
||||
|
||||
Refreshes gnoma's per-arm routing defaults so that out-of-the-box
|
||||
selection produces sensible choices without requiring users to write
|
||||
a `[[arms]]` block in TOML. Surfaced during the 2026-05-23 session
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Startup Safety + Context Banner — 2026-05-23
|
||||
|
||||
> **Status: shipped in v0.3.0.** Commits `3eeb5b4` (classifier +
|
||||
> banner + main.go wiring) → `8ba77c1` (env-template precision
|
||||
> fix, label alignment, banner-under-bypass). All S-1 through
|
||||
> S-7 tasks complete; S-8 docs done in `d206b3c`. Windows path
|
||||
> handling still deferred per plan.
|
||||
|
||||
Adds a pre-launch safety check that warns or refuses when gnoma is
|
||||
started in a directory where it could do real damage (`$HOME`,
|
||||
`/`, `/etc`, etc.), plus a context banner shown on every launch
|
||||
|
||||
@@ -15,7 +15,7 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/openai/openai-go v1.12.0
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/text v0.37.0
|
||||
google.golang.org/genai v1.52.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.0
|
||||
@@ -63,10 +63,10 @@ require (
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/api v0.267.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
|
||||
@@ -142,18 +142,18 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestTryLoadOAuthCredentials_Formats(t *testing.T) {
|
||||
name: "camelCase and milliseconds expiry",
|
||||
data: oauthCreds{
|
||||
AccessToken2: "token-camel",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixNano() / 1e6,
|
||||
ExpiresAt: time.Now().Add(1*time.Hour).UnixNano() / 1e6,
|
||||
TokenType2: "Bearer",
|
||||
},
|
||||
expectError: false,
|
||||
|
||||
@@ -109,8 +109,19 @@ var knownAgents = []CLIAgent{
|
||||
// structured-output flag and no image-input mechanism. JSON support
|
||||
// is faked via PromptResponseFormat (best-effort, model-dependent);
|
||||
// see TODO.md for tracking native stream-json support.
|
||||
//
|
||||
// ToolUse is false on purpose. agy streams plain text and the
|
||||
// agyParser turns every line into an EventTextDelta — there is
|
||||
// no path for a structured ToolCall event to come back. With
|
||||
// ToolUse=true the router would dispatch tool-needing tasks
|
||||
// (security_review, spawn_elfs, file edit) to agy; the
|
||||
// underlying Gemini model would describe calling the tool in
|
||||
// prose (invented UUIDs and "I will pause now"-style stubs),
|
||||
// the engine would receive only text, and the turn would hang
|
||||
// waiting for a tool call that never arrives. Flip back to
|
||||
// true when native stream-json lands.
|
||||
Capabilities: provider.Capabilities{
|
||||
ToolUse: true,
|
||||
ToolUse: false,
|
||||
ContextWindow: 200000,
|
||||
},
|
||||
PromptResponseFormat: true,
|
||||
|
||||
@@ -338,10 +338,10 @@ func TestRoutingDefaults_PayoffScenario(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
task Task
|
||||
wantArmID ArmID
|
||||
reason string
|
||||
name string
|
||||
task Task
|
||||
wantArmID ArmID
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Generation picks qwen3-coder",
|
||||
@@ -472,4 +472,3 @@ func TestRoutingDefaults_LocalFleetVisibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,10 @@ func TestPolicyMultiplier(t *testing.T) {
|
||||
cloudArm := &Arm{IsLocal: false}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
arm *Arm
|
||||
policy PreferPolicy
|
||||
want float64
|
||||
name string
|
||||
arm *Arm
|
||||
policy PreferPolicy
|
||||
want float64
|
||||
}{
|
||||
{"auto/local", localArm, PreferAuto, 1.0},
|
||||
{"auto/cloud", cloudArm, PreferAuto, 1.0},
|
||||
|
||||
+10
-10
@@ -10,16 +10,16 @@ import (
|
||||
// Caller passes whatever is known at launch time; empty fields are
|
||||
// omitted from the rendered banner.
|
||||
type SessionInfo struct {
|
||||
Version string // e.g. "0.2.1"
|
||||
GitBranch string // empty if not in a git repo
|
||||
GitDirty bool // true if working tree has uncommitted changes
|
||||
ProjectType string // free-form, e.g. "Go module (somegit.dev/...)"
|
||||
Provider string // e.g. "ollama"
|
||||
Model string // e.g. "qwen3-coder:30b"
|
||||
Permission string // e.g. "auto", "accept_edits"
|
||||
Incognito bool
|
||||
Prefer string // "auto" / "local" / "cloud"
|
||||
Tenant string // optional, e.g. Kubernetes context name
|
||||
Version string // e.g. "0.2.1"
|
||||
GitBranch string // empty if not in a git repo
|
||||
GitDirty bool // true if working tree has uncommitted changes
|
||||
ProjectType string // free-form, e.g. "Go module (somegit.dev/...)"
|
||||
Provider string // e.g. "ollama"
|
||||
Model string // e.g. "qwen3-coder:30b"
|
||||
Permission string // e.g. "auto", "accept_edits"
|
||||
Incognito bool
|
||||
Prefer string // "auto" / "local" / "cloud"
|
||||
Tenant string // optional, e.g. Kubernetes context name
|
||||
}
|
||||
|
||||
// RenderContextBanner returns the always-shown banner with cwd, git,
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestScanCWDForSensitive_Matches(t *testing.T) {
|
||||
}
|
||||
// Non-sensitive control files.
|
||||
control := []string{
|
||||
".envrc", // direnv config, not a credential
|
||||
".envrc", // direnv config, not a credential
|
||||
"main.go",
|
||||
"README.md",
|
||||
"secret_handler.go", // source code, not data
|
||||
|
||||
+23
-1
@@ -1403,6 +1403,28 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
|
||||
m.injectSystemContext(msg)
|
||||
return m, nil
|
||||
|
||||
case "/router":
|
||||
if m.config.Router == nil {
|
||||
m.messages = append(m.messages, chatMessage{role: "error", content: "router not configured"})
|
||||
return m, nil
|
||||
}
|
||||
if args == "" || args == "help" {
|
||||
current := m.config.Router.PreferPolicy().String()
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("router.prefer = %s\nUsage: /router <auto|local|cloud>\n auto — no bias; tier order + Strengths decide\n local — cloud arms demoted; locals win when feasible\n cloud — local arms demoted; cloud arms win (except tier-0 SLM)", current)})
|
||||
return m, nil
|
||||
}
|
||||
policy, err := router.ParsePreferPolicy(args)
|
||||
if err != nil {
|
||||
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
m.config.Router.SetPreferPolicy(policy)
|
||||
msg := fmt.Sprintf("router.prefer = %s (runtime override; not written to config)", policy.String())
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: msg})
|
||||
m.injectSystemContext(msg)
|
||||
return m, nil
|
||||
|
||||
case "/profile":
|
||||
if args == "" {
|
||||
m = m.closeAllPickers()
|
||||
@@ -1532,7 +1554,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /profile [name] list profiles / switch (re-execs gnoma)\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use /<name> [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"})
|
||||
content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /profile [name] list profiles / switch (re-execs gnoma)\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /router [mode] show or set routing preference (auto/local/cloud)\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use /<name> [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"})
|
||||
return m, nil
|
||||
|
||||
case "/keys":
|
||||
|
||||
@@ -22,7 +22,10 @@ var builtinCommands = []cmdEntry{
|
||||
{"/exit", "exit gnoma"},
|
||||
{"/help", "show available commands and shortcuts"},
|
||||
{"/incognito", "toggle incognito mode (no persistence, local-only routing)"},
|
||||
{"/init", "initialize project — create AGENTS.md"},
|
||||
// /init is provided by the bundled skill at
|
||||
// internal/skill/skills/init.md; do not duplicate it here. The dedup
|
||||
// in completionSource() would skip a duplicate entry anyway, but
|
||||
// omitting it keeps the source-of-truth single.
|
||||
{"/keys", "show keyboard shortcuts"},
|
||||
{"/model", "list or switch active model"},
|
||||
{"/new", "start a new conversation"},
|
||||
@@ -34,6 +37,7 @@ var builtinCommands = []cmdEntry{
|
||||
{"/quit", "quit gnoma"},
|
||||
{"/replay", "replay last assistant response"},
|
||||
{"/resume", "browse and resume a saved session"},
|
||||
{"/router", "show or set routing preference (auto/local/cloud)"},
|
||||
{"/shell", "open interactive shell"},
|
||||
{"/theme", "list themes or set active theme"},
|
||||
{"/skills", "list available skills"},
|
||||
@@ -46,11 +50,27 @@ var permissionModes = []string{
|
||||
"auto", "default", "accept_edits", "bypass", "deny", "plan",
|
||||
}
|
||||
|
||||
// completionSource builds a sorted command list from builtins + skills.
|
||||
func completionSource(skills *skill.Registry) []cmdEntry {
|
||||
entries := make([]cmdEntry, len(builtinCommands))
|
||||
copy(entries, builtinCommands)
|
||||
// routerPreferModes lists valid values for /router completion.
|
||||
var routerPreferModes = []string{"auto", "local", "cloud"}
|
||||
|
||||
// completionSource builds a sorted command list from builtins + skills.
|
||||
// Skill names shadow builtin names so a skill (bundled or user-defined)
|
||||
// can replace a static entry without producing a duplicate in the picker.
|
||||
func completionSource(skills *skill.Registry) []cmdEntry {
|
||||
skillNames := make(map[string]struct{})
|
||||
if skills != nil {
|
||||
for _, s := range skills.All() {
|
||||
skillNames["/"+s.Frontmatter.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]cmdEntry, 0, len(builtinCommands)+len(skillNames))
|
||||
for _, c := range builtinCommands {
|
||||
if _, shadowed := skillNames[c.name]; shadowed {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, c)
|
||||
}
|
||||
if skills != nil {
|
||||
for _, s := range skills.All() {
|
||||
desc := s.Frontmatter.Description
|
||||
@@ -150,6 +170,16 @@ func matchArgCompletion(input string, profileNames []string, providerNames []str
|
||||
return cmd + " " + mode
|
||||
}
|
||||
}
|
||||
case "/router":
|
||||
if arg == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(arg)
|
||||
for _, mode := range routerPreferModes {
|
||||
if strings.HasPrefix(mode, lower) && mode != arg {
|
||||
return cmd + " " + mode
|
||||
}
|
||||
}
|
||||
case "/profile":
|
||||
if arg == "" || len(profileNames) == 0 {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user