diff --git a/CHANGELOG.md b/CHANGELOG.md index b078e756..763e722e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,77 @@ Each version's section is also what the app's update dialog shows. +## 0.7.0 + +Claude Platform, enowX's own agent; Project Docs; the community library at https://ui.enowx.ai; Claude Code now always running in bypass; a fifth of the scrollback; and much less memory for the app and the daemon. + +- **Claude Platform, enowX's own coding agent.** It is part of enowX's program: a Go loop over Anthropic's Messages API, nothing to install (no Node, no Python) on any host, macOS, Linux, Windows and servers alike. It runs on a key from the vault and is billed per token by Anthropic. Its tools are the ones Claude Code names (Read, Write, Edit, Glob, Grep, Bash, AskUserQuestion, Task, and MCP servers' tools), its permission prompts are answered by keys, a hook can rewrite what it runs, and "always allow" keeps only a bounded command. + - **Its sessions are sessions like any other:** tabs, hibernation, restarts, other hosts. Its chat streams the answer as it arrives, a chip shows what the session has cost against its budget, and its sheet keeps the API key in the vault and edits the host's system prompt. + - **The key stays the agent's:** its tools' commands don't get it, it is in no file, command line, record or log, and text a tool brings back has it replaced. + - **Not offered:** rewinding from the chat and forking a session. +- **Project Docs: a whole project documented as notes.** A PRD, Technical Spec, ERD and the rest live as ordinary notes under one project note, so sync, teams, history, RAG and a note's agent work on them. Its metadata and approval line sit in the body, where an older daemon can't lose them; sections say what they are built on, with that source's hash, and read as stale when it moves on. Agents write to an approved document as a proposal you accept, and the approval gate blocks building until the PRD and Technical Spec are approved and unchanged. + - Templates for product, UX, planning, launch and the technical areas; `docs` on the command line and as MCP tools; `goa delegate --doc` hands a task over with the sections, links and acceptance criteria it needs; a project exports to a repository when you ask. +- **The community library, enowX UI, at https://ui.enowx.ai.** Designs are shared from canvas to the site and browsed in the app: search by words and by meaning, filters, likes, collections, follows, comments, notifications for what you made and what was decided, and moderation by people who aren't its creator. Take a design as code, as a reference or as a skill, and `enowx://` links open in the app. It stays off unless the cloud you set has it on, and nothing reaches any other service. +- **GOD of Agents can change the agent and config it runs on,** from Settings in its header, with what a pick would do or why it can't before anything is pressed. It is offered only on agents run for real as GOA (Claude Code and its teams, Codex, OpenCode 1 and 2; Claude Platform ahead of that run), and refuses the others saying why. + - **One line per turn,** the task's report and its turn's end together, with the worker's real last message. + - **A queued task survives a daemon's restart,** reaches a session on another host, and brings a sleeping session back for it. + - **A task under way, or a session's background agents, keep hibernation off it.** +- **Claude Code always runs in `bypassPermissions`, for every user, with no way to turn it off.** Every Claude Code session enowX starts or brings back runs in bypass: New session, `spawn`, `goa delegate`, resume, `continue`, `wake`, a message to one asleep, fork, a tab, a session copied from another host, an account switch, a daemon's restart and an app's update, whatever an earlier daemon saved. + - **No setting turns it off:** the New session switch and Settings' permission mode aren't offered for Claude Code, and one an app, the command line or an automation still sends is ignored. The rule is Claude Code's alone; another agent doesn't inherit it. + - **enowX accepts Claude Code's warning for you:** on a home that never accepted it, Claude Code stops at a warning whose first answer exits, so enowX passes the setting that skips it in the `--settings` it starts Claude Code with. Your own files aren't touched, and the session's banner says it skips permission prompts. + - **What still stops it:** a hook's refusal (a deny, which bypass doesn't pass), the judge's sure no, and a hook's ask, which Claude Code still prompts for. As root Claude Code refuses bypass itself, and the session starts asking and says why. A mode you set mid-session holds for that process only; a restart, a wake or an update brings bypass back. +- **RAG searches on every prompt of an indexed project,** and adds the four best parts, up to 6000 characters. Nothing turns it off: not a switch, not the judge. The judge's "RAG: context or not" use is retired, and settings that name it are read and ignored. + - **That is one call to the embedder per prompt.** Past 3 seconds the prompt goes on without the index; a failed embedder falls back to the words alone, in the host's own store, the parts say so, and the failure shows in Settings → RAG and the Index tool until a search works. + - **Agents with a start-of-prompt hook get the parts:** Claude Code, Claude Agent Teams, Codex and Claude Platform. Others have no moment of the prompt to answer, and search with `rag search` themselves. +- **Default scrollback falls from 10 to 2 MiB a session.** The daemon's own memory was mostly this: five full scrollbacks measured 65 MB before and 10 MB after, about 11 MB a full session. A long build's log is cut sooner; an agent's chat isn't affected, being read from its transcript. +- **Switching a session's model restarts it on its conversation.** The chat's composer, `switch`, GOA's **Change model** and `goa delegate` change that session alone. Claude Code and Codex start again on their conversation with the model and effort as flags, never `/model`, so your own default in your settings isn't touched. + - **Refused while its agent works or asks,** when stopping would lose what it runs or serves, and on a session running a config, which decides its model. Nothing changes then, and GOA still gives the task, its board line saying the session kept its model. A session that isn't running only takes the new values, for when it comes back. An older daemon answers that it can't, and nothing is typed in its place. +- **Much less memory, and the numbers to show it.** A session's terminal is parsed only while it is shown, the last-shown few are let go after 3 minutes, a canvas's layers are drawn as asked for, a conversation is held only while a chat shows it, a chat polls only on screen, and an idle sync does nothing. + - **The token store keeps the last 2 days of calls** and sums older days: it held 45.6 MB for 126,000 calls before. Every split, day row, budget and session badge stays exact. + - **A team's pull that was cut off carries on where it stopped,** and a chat's refresh after a completed sync asks the cloud for nothing; before, a re-check read the whole space again every 5 seconds. + - **GOGC 50, a 1 GiB soft limit** well above ordinary use, and memory returned after a burst. + - **`enowx-app profile`** measures the running app and daemon: memory by the system's count and by Go's, what holds the heap, what they keep, CPU and pprof files; the same report goes in a diagnostics bundle. +- **Agents:** + - **OMP takes a model API per session:** a folder of the session's own with `models.yml`, the key by variable and `--model enowx/`. Run for real on OMP 18.15 on a throwaway daemon. + - **OpenCode 2:** enowX starts `opencode --standalone` when it reports 2 or later, so a config's provider, model, guide and tools aren't ignored by the user's shared background server, and the models' context limits come from the config. + - **Kilo Code takes its first prompt by flag.** + - **The guide comes in two parts:** an inject made for each session (who it is, the rules that hold whatever a file says, GOA's duty to delegate) and a guide file outside any repository. An agent with a file form gets both as one system prompt; one with only an inline flag gets the inject, told to read the guide. Nothing of it lands in your project. + - **Agents with no other way get a marked block** at the top of the project's `AGENTS.md`, or its `CLAUDE.md`, only in a session's environment. It is on by default, a tracked file shows it in `git diff`, and `project settings --guide-block off` takes it out. +- **Each session shows its own conversation,** not the newest file in its folder: by the ID its agent reported, by its own session folder, or the first unclaimed conversation started after it. Resuming, forking, importing and the model shown all go by that choice. +- **Sleeping sessions, and updates that don't cut work off.** Hibernating by hand shows what would stop (a dev server, ports, background agents, a task under way) before it happens, and a hibernated or ended agent comes back on its conversation when something is sent to it. Automatic hibernation stays at 30 idle minutes by default and never takes a session with a task under way or background agents; one can be kept out of it, and the choice syncs. + - The app replaces its daemon only when a restart would stop nothing, asking the daemon first; otherwise a card offers **Restart it now** or **When agents are done**. Sessions survive an update and a reconnect, and what a restart stops comes back. +- **Also fixed:** + - A platform tab gets its own key from the vault, and a credential's use in a tab is recorded as that tab's. + - A chat no longer shows its folder's newest conversation instead of the session's own. + - An OpenCode 2 session is given its model's context limit. + - The model shown follows a resumed, forked or imported session instead of its folder. + - **Always allow** no longer allows every command, and hooks are obeyed fully. + - The agent's own API key is no longer handed to the commands it runs. + - Nothing is typed into the shell an agent leaves behind. + - Permission checks that a reader could see differently were closed (reads, MCP hints, attachments, kept rules). + - A canvas renders through an app that only watches the host, and a page whose address changes says so. + - A note's agent starts anew when its conversation left with its session. + - An agent's transcripts are found for a folder reached behind a symlink. + - A late turn's end no longer lands on a task being typed in. + - A team's items that waited aren't lost; a dropped connection backs off instead of hammering a host as it comes up. + - Menus, dropdowns and pop-up lists stay inside the window. + +### When you update + +- **The first start restarts the daemon,** as with any update. It now waits while a restart would stop work (an agent at work or asking, a command running in a terminal), and offers to do it later instead. Agents that do stop come back on their conversation. +- **Update every device and host together.** A canvas's versions have stopped being synced since 0.6.0: an older app given no manifest would delete them, so an older app's sync fails and says to update. +- **Claude Code sessions in bypass:** nothing to do, it is on for every session from now on, and its warning won't show. + +### What hasn't been tested with the real app + +- **Claude Platform** ran end to end against a stand-in Messages API and, for its key, with the real program on a throwaway daemon; it hasn't run a release build against a real Anthropic account at this scale, and the model's own behavior under bypass wasn't judged. +- **The community library** has automated tests for its cloud side on SQLite and Postgres, and none of its moderation, comments, notifications or numbers has been used by real people yet. +- **Project Docs** is covered by tests and the app's pages; a whole real project hasn't gone from a brainstorm through approval, tasks and export. +- **The memory numbers** were measured on Linux, step by step in [docs/performance.md](https://github.com/enowdev/enowx/blob/main/docs/performance.md) (available in the app's docs). The Mac's figures there are estimates from one profile; the scrollback change wasn't re-measured on a Mac, and the app's and daemon's footprints on macOS weren't taken. +- **Agents' real sessions:** Claude Code 2.1.318, Codex 0.166, OpenCode 1.19.5, Claude Agent Teams, OMP 18.16 and Qwen Code 0.26.7 were checked with a stand-in model that records what they receive; Gemini CLI 0.66 only partly. The others are read from their documentation and aren't counted as working: start one and ask it who it is in enowX. +- **Claude Code's bypass** was checked on 2.1.318 with its answers served locally; that a real account's first session never sees the warning is by the setting enowX passes, not by a run with Anthropic's API. +- **Windows** has the workflow's job for the pieces that must look the same there (icons, command lines through real PowerShell and cmd.exe, quoting), not the whole test suite. + ## 0.6.2 Fixes the app dropping its connection when you move to Canvas with large designs, and adds logs that say why a connection ended, so a problem like this can be read afterwards. diff --git a/cli/control.go b/cli/control.go index 37f84cc1..c7ae248e 100644 --- a/cli/control.go +++ b/cli/control.go @@ -11,6 +11,7 @@ import ( "strings" "text/tabwriter" + "github.com/enowx/enowx/client" "github.com/enowx/enowx/core/agent" "github.com/enowx/enowx/proto" ) @@ -372,26 +373,24 @@ func (r runner) switchSession(args []string) error { return errors.New(usage) } set := agent.SettingsOf(s.Agent) - typeIn := func(what, command, v string, choices []agent.Choice) error { - if command == "" { - return fmt.Errorf("switch: %s's %s can't be changed while it runs", nonEmpty(s.Agent, "this program"), what) + note := func(what, v string, choices []agent.Choice) { + if v == "" || len(choices) == 0 || slices.ContainsFunc(choices, func(c agent.Choice) bool { return c.ID == v }) { + return } - if len(choices) > 0 && !slices.ContainsFunc(choices, func(c agent.Choice) bool { return c.ID == v }) { - var ids []string - for _, c := range choices { - ids = append(ids, c.ID) - } - fmt.Fprintf(r.out, "(%s isn't among the %ss enowX knows of %s: %s; sent anyway)\n", v, what, s.Agent, strings.Join(ids, ", ")) + var ids []string + for _, c := range choices { + ids = append(ids, c.ID) } - return r.c.Input(s.ID, fmt.Sprintf(command, v), false, true) - } - if *model != "" { - if err := typeIn("model", set.ModelCommand, *model, set.Models); err != nil { - return err + fmt.Fprintf(r.out, "(%s isn't among the %ss enowX knows of %s: %s; asked anyway)\n", v, what, s.Agent, strings.Join(ids, ", ")) + } + if *model != "" || *effort != "" { + note("model", *model, set.Models) + note("effort", *effort, set.Efforts) + err := r.c.SwitchSettings(proto.SessionSettingsParams{ID: s.ID, Model: *model, Effort: *effort}) + if client.IsUnknownMethod(err) { + return errors.New("switch: this host's enowX is too old to change one session's model alone: update it. The session is as it was") } - } - if *effort != "" { - if err := typeIn("effort", set.EffortCommand, *effort, set.Efforts); err != nil { + if err != nil { return err } } diff --git a/cli/decide_route.go b/cli/decide_route.go index 768711fb..23f74911 100644 --- a/cli/decide_route.go +++ b/cli/decide_route.go @@ -51,6 +51,8 @@ func (r runner) decideRoute(p proto.DecideRouteParams, asJSON bool) error { fmt.Fprintf(r.out, "model: %s · effort: %s\n", model, effort) if len(out.Switch) > 0 { fmt.Fprintf(r.out, "in a running session: %s\n", strings.Join(out.Switch, ", ")) + } else if model != "its own" || effort != "its own" { + fmt.Fprintln(r.out, "in a running session: enowx-app switch SESSION with --model and --effort (typing its /model would change the user's default)") } return nil } diff --git a/client/transcript.go b/client/transcript.go index 9ae65b29..3fe222c0 100644 --- a/client/transcript.go +++ b/client/transcript.go @@ -22,6 +22,11 @@ func (c *Client) SwitchMode(id, mode string) error { return c.Call(proto.MethodSessionMode, proto.SessionModeParams{ID: id, Mode: mode}, nil) } +// SwitchSettings changes a session's model, its effort or both (SessionSettingsParams). +func (c *Client) SwitchSettings(p proto.SessionSettingsParams) error { + return c.Call(proto.MethodSessionSettings, p, nil) +} + // Rewind takes session id's agent back to before the user's message back // messages from the end, whose text is text; the message sent next takes // its place. diff --git a/cmd/enowx/mygo.json b/cmd/enowx/mygo.json index 021db508..e1d5820b 100644 --- a/cmd/enowx/mygo.json +++ b/cmd/enowx/mygo.json @@ -1,7 +1,7 @@ { "name": "enowX", "identifier": "ai.enowx.app", - "version": "0.6.2", + "version": "0.7.0", "copyright": "© 2026 enowX contributors", "out": "../../build", "urlSchemes": ["enowx"], diff --git a/core/accounts/ways.go b/core/accounts/ways.go index 5c73550e..565f8ae2 100644 --- a/core/accounts/ways.go +++ b/core/accounts/ways.go @@ -71,7 +71,7 @@ var agentWays = map[string]Way{ "qoder-cn": {Key: true, Why: "A model is added only in its /model screen: its docs say not to edit the settings by hand. No other account: QODERCN_CONFIG_DIR moves its folder, but its docs name no file for the sign-in.", Checked: "docs"}, "zcode": {Why: "Its docs describe a desktop app, with no command line to give a config to.", Checked: "docs"}, "pi": {Key: true, Why: "Another account is read from its docs, not signed in: auth.json in PI_CODING_AGENT_DIR is the account's. It has no sign-in command: use /login in a session on the config.", Checked: "docs"}, - "omp": {Key: true, Why: "It takes a provider's address, but only for models named in models.yml, which enowX doesn't write yet: a model of your own wasn't found when run.", Checked: "run"}, + "omp": {Key: true, Why: "A config's sessions read only the config's models, not your own ~/.omp settings, skills and sign-in: a folder of the session's own is the only way to give one another models.yml.", Checked: "run"}, "prime-agent": {Key: true, Why: "Only a models.json takes a model API, at a place its docs don't name.", Checked: "docs"}, "antigravity": {Key: true, Why: "No separate account is documented.", Checked: "help"}, "aider": {Key: true, Why: "No account: it has no sign-in to separate, only API keys.", Checked: "docs"}, diff --git a/core/agent/settings.go b/core/agent/settings.go index 8b7cbab1..da680a9a 100644 --- a/core/agent/settings.go +++ b/core/agent/settings.go @@ -23,7 +23,8 @@ type Choice struct { // is changed in a running session. type Settings struct { Models []Choice - // ModelCommand, with %s the model's ID, switches the model. + // ModelCommand, with %s the model's ID, switches the model in the session it runs in; + // where it saves it for every session too (SavesDefaults), enowX doesn't type it. ModelCommand string Modes []Choice // ModeTitle names the modes in the composer; "" for "Permissions". @@ -32,8 +33,13 @@ type Settings struct { // mode can't be changed while the agent runs. ModeKey string Efforts []Choice - // EffortCommand, with %s the level, sets the effort. + // EffortCommand, with %s the level, sets the effort, as ModelCommand sets the model. EffortCommand string + // SavesDefaults says ModelCommand and EffortCommand save what they set as the default + // for new sessions, in a file all the agent's sessions read: typing one would change + // the user's own settings too, so enowX starts the agent again on its conversation + // with ModelFlag and EffortFlag instead (session.settings). + SavesDefaults bool // ModelFlag, EffortFlag and ModeFlag, with %s the choice, start the agent on a // model, an effort and a permission mode: arguments of its command line. ModelFlag, EffortFlag, ModeFlag []string @@ -125,6 +131,7 @@ var claudeSettings = Settings{ {ID: "xhigh", Label: "Extra high"}, {ID: "max", Label: "Max"}, }, EffortCommand: "/effort %s", + SavesDefaults: true, ModelFlag: []string{"--model", "%s"}, EffortFlag: []string{"--effort", "%s"}, ModeFlag: []string{"--permission-mode", "%s"}, @@ -153,6 +160,7 @@ var codexSettings = Settings{ {ID: "xhigh", Label: "Extra high"}, {ID: "max", Label: "Max"}, }, EffortCommand: "/model --reasoning=%s", + SavesDefaults: true, // "failed to persist model selection", in 0.166's program; not run ModelFlag: []string{"-c", `model="%s"`}, EffortFlag: []string{"-c", `model_reasoning_effort="%s"`}, } diff --git a/core/agentapi/session.go b/core/agentapi/session.go index fe8f529e..9682f679 100644 --- a/core/agentapi/session.go +++ b/core/agentapi/session.go @@ -250,11 +250,20 @@ func addOpenCode2Provider(path string, api API) error { return err } models := map[string]any{} + add := func(m Model) { + entry := map[string]any{"name": m.ID} + // A model without one is assumed 200,000 context and 32,000 output, which + // a session's footer then shows against (2.2.5). + if l := openCodeLimit(m); l != nil { + entry["limit"] = l + } + models[m.ID] = entry + } for _, m := range api.Models { - models[m.ID] = map[string]any{"name": m.ID} + add(m) } - if api.Model != "" { - models[api.Model] = map[string]any{"name": api.Model} + if api.Model != "" && models[api.Model] == nil { + add(Model{ID: api.Model}) } provider := map[string]any{"name": "enowX config", "package": "@opencode/ai/providers/openai-compatible", "settings": map[string]any{"baseURL": api.BaseURL}, "models": models} @@ -269,6 +278,17 @@ func addOpenCode2Provider(path string, api API) error { return os.WriteFile(path, out, 0o600) } +// openCodeLimit is a model's limits as OpenCode 2 takes them in a provider's +// models: both counts, which it takes only together (its schema requires both), +// or nil when the model gives neither, leaving it OpenCode's own assumption. +// The defaults are 1.x's in the same file, so both forms say the same. +func openCodeLimit(m Model) map[string]any { + if m.Context <= 0 && m.Output <= 0 { + return nil + } + return map[string]any{"context": orDefault(m.Context, 128000), "output": orDefault(m.Output, 16384)} +} + // envSession is an agent that takes its API from the environment only. func envSession(a adapter) sessionFunc { return func(api API, _ string) (Launch, error) { diff --git a/core/agentapi/session_omp.go b/core/agentapi/session_omp.go new file mode 100644 index 00000000..1b74d537 --- /dev/null +++ b/core/agentapi/session_omp.go @@ -0,0 +1,86 @@ +package agentapi + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +// OMP reads its providers and models from models.yml in its agent folder +// (PI_CODING_AGENT_DIR, ~/.omp/agent by default), and an apiKey that names a +// variable is taken from the environment (resolveConfigValue, $envExact), so a +// session's file can name the key without holding it. A folder of the +// session's own is the only way to give it another models.yml: there is no +// flag for a second one, and --config takes a config.yml overlay alone. + +// omp is OMP's way of taking an API: per session alone, its own files never +// written, as a key written there would be a key in a file. +var omp = adapter{ + protocols: []string{OpenAI, OpenAIResponses, Anthropic}, + caps: listCaps, + note: "It needs the model's name. A config's sessions read only the config's models: your own ~/.omp settings, skills and sign-in don't reach them.", +} + +// ompKeyVar holds the key of an OMP session's API: its models.yml names it. +const ompKeyVar = "ENOWX_OMP_API_KEY" + +// ompAPIs are the protocols OMP speaks, by its names for them. +var ompAPIs = map[string]string{OpenAI: "openai-completions", OpenAIResponses: "openai-responses", Anthropic: "anthropic-messages"} + +func init() { + sessionOnly["omp"] = omp + sessionAdapters["omp"] = ompSession +} + +// ompSession: an agent folder of the session's own (PI_CODING_AGENT_DIR), with +// a models.yml holding the config's provider and models, and the model named on +// its command line. The user's ~/.omp/agent is left as it is. +func ompSession(api API, dir string) (Launch, error) { + if api.Model == "" { + return Launch{}, errors.New("agentapi: OMP needs the model's name") + } + agentDir := filepath.Join(dir, "agent") + if err := os.MkdirAll(agentDir, 0o700); err != nil { + return Launch{}, err + } + if err := os.WriteFile(filepath.Join(agentDir, "models.yml"), ompModels(api), 0o600); err != nil { + return Launch{}, err + } + // A folder of its own would run OMP's setup wizard first, asking for the + // model the session already has. + return Launch{ + Env: kv([2]string{"PI_CODING_AGENT_DIR", agentDir}, [2]string{"OMP_SKIP_SETUP", "1"}, [2]string{ompKeyVar, api.APIKey}), + Args: []string{"--model", ourName + "/" + api.Model}, + }, nil +} + +// ompModels is models.yml for api: one provider, enowX's, with the API's +// address, protocol and models, its key named rather than written. +func ompModels(api API) []byte { + var b strings.Builder + b.WriteString("providers:\n " + ourName + ":\n") + b.WriteString(" baseUrl: " + yamlQuote(api.BaseURL) + "\n") + b.WriteString(" api: " + yamlQuote(ompAPIs[api.Protocol]) + "\n") + if api.APIKey != "" { + b.WriteString(" apiKey: " + yamlQuote(ompKeyVar) + "\n") + } else { + b.WriteString(" auth: none\n") // a server that asks for no key + } + b.WriteString(" models:\n") + for _, m := range api.Models { + b.WriteString(" - id: " + yamlQuote(m.ID) + "\n") + b.WriteString(" name: " + yamlQuote(m.ID) + "\n") + if m.Context > 0 { + b.WriteString(" contextWindow: " + strconv.Itoa(m.Context) + "\n") + } + if m.Output > 0 { + b.WriteString(" maxTokens: " + strconv.Itoa(m.Output) + "\n") + } + if m.Vision { + b.WriteString(" input: [\"text\", \"image\"]\n") + } + } + return []byte(b.String()) +} diff --git a/core/agentapi/session_routes_test.go b/core/agentapi/session_routes_test.go index f181eaf5..dbdb04b4 100644 --- a/core/agentapi/session_routes_test.go +++ b/core/agentapi/session_routes_test.go @@ -65,6 +65,8 @@ func TestSessionRoutes(t *testing.T) { "GOOSE_PROVIDER__HOST=" + url, "GOOSE_PROVIDER__API_KEY=" + routeKey, "ANTHROPIC_HOST=" + url, "ANTHROPIC_API_KEY=" + routeKey}}, "crush": {id: "crush", api: one(""), env: []string{"CRUSH_GLOBAL_CONFIG={dir}/crush", "CRUSH_GLOBAL_DATA={dir}/crush-data", crushKeyVar + "=" + routeKey}, file: "crush/crush.json", ref: "$" + crushKeyVar}, + "omp": {id: "omp", api: one(""), env: []string{"PI_CODING_AGENT_DIR={dir}/agent", "OMP_SKIP_SETUP=1", ompKeyVar + "=" + routeKey}, + args: []string{"--model", "enowx/m"}, file: "agent/models.yml", ref: ompKeyVar}, } { t.Run(name, func(t *testing.T) { if !PerSession(tc.id) { @@ -137,6 +139,8 @@ func TestSessionRoutesRefuse(t *testing.T) { "goose without a model": {"goose", API{BaseURL: url, APIKey: routeKey}}, "kilo without a model": {"kilo", API{BaseURL: url, APIKey: routeKey}}, "continue, no model": {"continue", API{BaseURL: url, APIKey: routeKey}}, + "omp without a model": {"omp", API{BaseURL: url, APIKey: routeKey}}, + "omp's Gemini API": {"omp", API{BaseURL: url, APIKey: routeKey, Model: "m", Protocol: Gemini}}, } { if _, err := SessionLaunch(tc.id, tc.api, t.TempDir()); err == nil { t.Errorf("%s: started", name) @@ -157,6 +161,39 @@ func TestSessionConfigWithoutAKey(t *testing.T) { } } +// OMP reads its models from models.yml in the agent folder its sessions get, which +// is the session's own: the user's ~/.omp/agent is never read, nor written. +func TestOMPSessionWritesItsOwnModelsFile(t *testing.T) { + dir := t.TempDir() + api := API{BaseURL: "https://gw.example.com/v1", APIKey: routeKey, Model: "cb/m1", Protocol: OpenAI, + Models: []Model{{ID: "cb/m1", Vision: true, Context: 1_000_000, Output: 384_000}, {ID: "cb/m2", Context: 64_000}}} + l, err := SessionLaunch("omp", api, dir) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(dir, "agent", "models.yml")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`baseUrl: "https://gw.example.com/v1"`, `api: "openai-completions"`, `apiKey: "ENOWX_OMP_API_KEY"`, + `- id: "cb/m1"`, `contextWindow: 1000000`, `maxTokens: 384000`, `input: ["text", "image"]`, `- id: "cb/m2"`, `contextWindow: 64000`} { + if !strings.Contains(string(data), want) { + t.Errorf("models.yml is missing %q:\n%s", want, data) + } + } + if strings.Contains(string(data), routeKey) || strings.Contains(strings.Join(l.Args, " "), routeKey) { + t.Fatalf("the key is in the file or the command line: %s %q", data, l.Args) + } + // A server that asks for no key: nothing names a variable that isn't set. + l, err = SessionLaunch("omp", API{BaseURL: "http://localhost:8080/v1", Model: "m"}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if slices.ContainsFunc(l.Env, func(e string) bool { return strings.HasPrefix(e, ompKeyVar) }) { + t.Fatalf("env %q", l.Env) + } +} + // A providers.json of the user's would win over Copilot's variables: its // sessions read an empty one instead, the key staying in the environment. func TestCopilotSessionDoesNotReadTheUsersProviders(t *testing.T) { @@ -242,6 +279,39 @@ func TestOpenCodeConfigFitsBothVersions(t *testing.T) { } } +// OpenCode 2 reads a model's limits from the provider's entry in `providers` alone; a +// model without them is assumed 200,000 context and 32,000 output, which its footer +// then shows a session against. +func TestOpenCode2ConfigKeepsTheModelsLimits(t *testing.T) { + dir := t.TempDir() + l, err := SessionLaunch("opencode2", API{BaseURL: "https://x.example/v1", APIKey: "k", Model: "big", + Models: []Model{{ID: "big", Context: 1000000, Output: 384000}, {ID: "plain"}}}, dir) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Providers map[string]struct { + Models map[string]struct { + Limit *struct { + Context int `json:"context"` + Output int `json:"output"` + } `json:"limit"` + } `json:"models"` + } `json:"providers"` + } + data, err := os.ReadFile(strings.TrimPrefix(l.Env[0], "OPENCODE_CONFIG=")) + if err != nil || json.Unmarshal(data, &cfg) != nil { + t.Fatalf("%v: %s", err, data) + } + models := cfg.Providers["enowx"].Models + if lim := models["big"].Limit; lim == nil || lim.Context != 1000000 || lim.Output != 384000 { + t.Fatalf("big: %+v, want 1000000 and 384000 in %s", models["big"].Limit, data) + } + if models["plain"].Limit != nil { + t.Fatalf("plain: a limit it wasn't given: %+v", models["plain"].Limit) + } +} + // Claude Platform's key goes in its session's environment and nowhere else, and it has // no settings for one to be written to. func TestPlatformSessionKeyInItsEnvironmentAlone(t *testing.T) { diff --git a/core/decide/route.go b/core/decide/route.go index 74fc3dda..1b746da6 100644 --- a/core/decide/route.go +++ b/core/decide/route.go @@ -73,10 +73,14 @@ func Cheaper(difficulty string) string { return difficulty } -// Switch is what to type in a running session of agent id to change to r: -// its model command, its effort command, as it has them. +// Switch is what to type in a running session of agent id to change to r: its model command, +// its effort command, as it has them. None for an agent whose commands save what they set for +// all its sessions: session.settings changes those. func (r Route) Switch(id string) []string { s := agent.SettingsOf(id) + if s.SavesDefaults { + return nil + } var out []string if r.Model != "" && s.ModelCommand != "" { out = append(out, fmt.Sprintf(s.ModelCommand, r.Model)) diff --git a/core/decide/settings.go b/core/decide/settings.go index 800826a5..c987b31f 100644 --- a/core/decide/settings.go +++ b/core/decide/settings.go @@ -58,14 +58,20 @@ func (s *Settings) Set(st proto.DecideSettings) error { if err := checkPolicy(st.Policy, st.Provider); err != nil { return err } + uses := make(map[string]proto.DecideUse, len(st.Uses)) for use, u := range st.Uses { + if use == proto.DecideContext { + continue // retired: a prompt's search always adds what it found (docs/rag.md) + } if !slices.Contains(proto.DecideUses, use) { return fmt.Errorf("decide: no use %q", use) } if u.MinConfidence < 0 || u.MinConfidence > 1 || u.DailyCalls < 0 { return fmt.Errorf("decide: %s: how sure is 0 to 1, calls a day not negative", use) } + uses[use] = u } + st.Uses = uses for _, p := range st.Projects { if err := checkPolicy(p.Policy, p.Provider); err != nil { return err diff --git a/core/decide/uses.go b/core/decide/uses.go index 6de8e5e7..a891004b 100644 --- a/core/decide/uses.go +++ b/core/decide/uses.go @@ -10,9 +10,11 @@ import ( ) // The uses added after the first four: a prompt's difficulty (route), -// which search results bear on a prompt (context), whether an agent's last -// answer holds a decision to keep (memory), how a chat message notifies -// (chat), and whether a check failed by chance (flaky). +// whether an agent's last answer holds a decision to keep (memory), how a +// chat message notifies (chat), and whether a check failed by chance +// (flaky). proto.DecideContext is kept answerable for older settings, but +// the daemon never asks it: a prompt's project search always adds what it +// found (docs/rag.md). // More limits of what a judge is given, in characters. const ( diff --git a/core/decide/uses_test.go b/core/decide/uses_test.go index 956dbd73..48c809e5 100644 --- a/core/decide/uses_test.go +++ b/core/decide/uses_test.go @@ -29,9 +29,13 @@ func TestRouteFor(t *testing.T) { if r := RouteFor("claude", proto.VerdictEasy, nil); r != (Route{"claude-haiku-5-5", "low"}) { t.Fatalf("claude easy: %+v", r) } - if got := RouteFor("claude", proto.VerdictEasy, nil).Switch("claude"); strings.Join(got, "|") != "/model claude-haiku-5-5|/effort low" { + // Claude Code's commands would save them for all its sessions. + if got := RouteFor("claude", proto.VerdictEasy, nil).Switch("claude"); len(got) != 0 { t.Fatalf("switch %q", got) } + if got := RouteFor(agent.PlatformID, proto.VerdictEasy, nil).Switch(agent.PlatformID); strings.Join(got, "|") != "/model claude-haiku-5-5|/effort low" { + t.Fatalf("platform's switch %q", got) + } if r := RouteFor("codex", proto.VerdictHard, nil); r != (Route{"", "high"}) { t.Fatalf("codex hard: %+v", r) } diff --git a/core/hostcontrol/hostcontrol.go b/core/hostcontrol/hostcontrol.go index f9925476..ae377960 100644 --- a/core/hostcontrol/hostcontrol.go +++ b/core/hostcontrol/hostcontrol.go @@ -75,6 +75,7 @@ var methods = map[string]Level{ proto.MethodSessionInterrupt: Change, proto.MethodSessionAnswer: Change, proto.MethodSessionMode: Change, + proto.MethodSessionSettings: Change, proto.MethodSessionRename: Change, proto.MethodSessionNote: Change, proto.MethodSessionContinue: Change, diff --git a/core/rag/embed.go b/core/rag/embed.go index 7f3191b7..1ed24690 100644 --- a/core/rag/embed.go +++ b/core/rag/embed.go @@ -17,9 +17,10 @@ import ( ) // embedder turns texts into vectors: documents to index, or a query to -// search them with (some models embed the two apart). +// search them with (some models embed the two apart). onCall, when not nil, +// is told the tokens each request to the service counted. type embedder interface { - embed(ctx context.Context, texts []string, query bool) ([][]float32, error) + embed(ctx context.Context, texts []string, query bool, onCall func(tokens int64)) ([][]float32, error) // tokens is what it was sent since made, as its service counted. tokens() int64 } @@ -29,7 +30,7 @@ func newEmbedder(st proto.RAGSettings, key string) (embedder, error) { switch st.Embedder { case proto.RAGVoyage: return &httpEmbedder{kind: proto.RAGVoyage, url: "https://api.voyageai.com/v1/embeddings", key: key, - model: orString(st.Model, "voyage-code-3"), dim: st.Dim, batch: 64}, nil + model: EmbedderModel(st), dim: st.Dim, batch: 64}, nil case proto.RAGOpenAI: if st.Model == "" { return nil, errors.New("rag: name the embedding model") @@ -59,7 +60,7 @@ const embedBatchBytes = 300 << 10 func (h *httpEmbedder) tokens() int64 { return h.used.Load() } -func (h *httpEmbedder) embed(ctx context.Context, texts []string, query bool) ([][]float32, error) { +func (h *httpEmbedder) embed(ctx context.Context, texts []string, query bool, onCall func(int64)) ([][]float32, error) { out := make([][]float32, 0, len(texts)) for i := 0; i < len(texts); { // As many as fit: services limit a batch's tokens too. @@ -70,20 +71,24 @@ func (h *httpEmbedder) embed(ctx context.Context, texts []string, query bool) ([ } part := texts[i:end] i = end - vecs, err := h.call(ctx, part, query) + vecs, used, err := h.call(ctx, part, query) if err != nil { return nil, err } if len(vecs) != len(part) { return nil, fmt.Errorf("rag: the embedder gave %d vectors for %d texts", len(vecs), len(part)) } + if onCall != nil { + onCall(used) // one request's count, apart from the ones beside it + } out = append(out, vecs...) } return out, nil } -// call embeds one batch, again after a moment when the service is busy. -func (h *httpEmbedder) call(ctx context.Context, texts []string, query bool) ([][]float32, error) { +// call embeds one batch, again after a moment when the service is busy: it +// says the vectors and the tokens the service counted for them. +func (h *httpEmbedder) call(ctx context.Context, texts []string, query bool) ([][]float32, int64, error) { var body any switch h.kind { case proto.RAGVoyage: @@ -112,7 +117,10 @@ func (h *httpEmbedder) call(ctx context.Context, texts []string, query bool) ([] if try > 0 { select { case <-ctx.Done(): - return nil, ctx.Err() + if last != nil { + return nil, 0, last // what the service said, not only that time ran out + } + return nil, 0, ctx.Err() case <-time.After(wait): } wait = min(wait*2, 20*time.Second) @@ -128,11 +136,11 @@ func (h *httpEmbedder) call(ctx context.Context, texts []string, query bool) ([] wait = max(wait, after) continue case status >= 300: - return nil, fmt.Errorf("rag: the embedder: %d %s", status, short(raw)) + return nil, 0, fmt.Errorf("rag: the embedder: %d %s", status, short(raw)) } return h.parse(raw, texts) } - return nil, last + return nil, 0, last } // post sends data, and says the answer, its status and how long the @@ -161,18 +169,18 @@ func (h *httpEmbedder) post(ctx context.Context, data []byte) ([]byte, int, time return raw, resp.StatusCode, after, err } -func (h *httpEmbedder) parse(raw []byte, texts []string) ([][]float32, error) { +// parse is the service's answer: its vectors and the tokens it counted, +// estimated from the texts when it doesn't say (TEI never does), so that +// every request is counted, never silently nothing. +func (h *httpEmbedder) parse(raw []byte, texts []string) ([][]float32, int64, error) { if h.kind == proto.RAGTEI { var out [][]float32 if err := json.Unmarshal(raw, &out); err != nil { - return nil, fmt.Errorf("rag: the embedder's answer: %w", err) - } - n := 0 - for _, t := range texts { - n += len(t) / 4 // TEI doesn't count: about 4 characters a token + return nil, 0, fmt.Errorf("rag: the embedder's answer: %w", err) } - h.used.Add(int64(n)) - return out, nil + used := estimateTokens(texts) + h.used.Add(used) + return out, used, nil } var out struct { Data []struct { @@ -184,7 +192,7 @@ func (h *httpEmbedder) parse(raw []byte, texts []string) ([][]float32, error) { } `json:"usage"` } if err := json.Unmarshal(raw, &out); err != nil { - return nil, fmt.Errorf("rag: the embedder's answer: %w", err) + return nil, 0, fmt.Errorf("rag: the embedder's answer: %w", err) } vecs := make([][]float32, len(out.Data)) for i, d := range out.Data { @@ -194,8 +202,22 @@ func (h *httpEmbedder) parse(raw []byte, texts []string) ([][]float32, error) { } vecs[at] = d.Embedding } - h.used.Add(out.Usage.Total) - return vecs, nil + used := out.Usage.Total + if used == 0 { + used = estimateTokens(texts) // a service that doesn't count its tokens + } + h.used.Add(used) + return vecs, used, nil +} + +// estimateTokens is about what texts count, for a service that doesn't +// say: four characters a token, and at least one for a text. +func estimateTokens(texts []string) int64 { + var n int64 + for _, t := range texts { + n += int64(len(t)/4) + 1 + } + return n } // short is the start of a service's error, for a message. diff --git a/core/rag/engine.go b/core/rag/engine.go index e2c14c6d..b0f50546 100644 --- a/core/rag/engine.go +++ b/core/rag/engine.go @@ -46,6 +46,10 @@ type Engine struct { settings *Settings // KeyOf is the value of the vault's credential named cred. KeyOf func(cred string) (string, error) + // OnEmbed is told the tokens of every request to the embedder, for an + // index or a search (the folder the text was about, "" for a test), so + // that retrieval is counted as the index is. + OnEmbed func(project string, tokens int64) // keys are the embedder's key and the PostgreSQL URL typed in the app, // on this host alone; used before the vault's credentials. keys *hostkeys.Store @@ -174,6 +178,14 @@ func (e *Engine) secret(cred string) (string, error) { return e.KeyOf(cred) } +// count is the callback that tells OnEmbed what project's text was sent. +func (e *Engine) count(project string) func(int64) { + if e.OnEmbed == nil { + return nil + } + return func(tokens int64) { e.OnEmbed(project, tokens) } +} + // Test embeds a line with settings st (not kept) and, for PostgreSQL, // connects to it; it says the vector's size. func (e *Engine) Test(ctx context.Context, st proto.RAGSettings) (int, error) { @@ -181,7 +193,7 @@ func (e *Engine) Test(ctx context.Context, st proto.RAGSettings) (int, error) { if err != nil { return 0, err } - v, err := emb.embed(ctx, []string{"enowX checks the embedder"}, true) + v, err := emb.embed(ctx, []string{"enowX checks the embedder"}, true, e.count("")) if err != nil { return 0, err } @@ -198,6 +210,15 @@ func (e *Engine) Test(ctx context.Context, st proto.RAGSettings) (int, error) { return len(v[0]), nil } +// EmbedderModel is the model st's embedder asks for: its own, or the +// embedder's default, which is what the tokens were counted against. +func EmbedderModel(st proto.RAGSettings) string { + if st.Embedder == proto.RAGVoyage { + return orString(st.Model, "voyage-code-3") + } + return st.Model +} + // EmbedderHost is where st's embedder sends text, for the user to agree // to: a host, or "this device". func (e *Engine) EmbedderHost(st proto.RAGSettings) string { @@ -267,7 +288,7 @@ func (e *Engine) Index(ctx context.Context, dir string, p proto.RAGProject, docs } } before := emb.tokens() - added, err := embedAll(ctx, emb, s, key, fresh) + added, err := embedAll(ctx, emb, s, key, fresh, e.count(dir)) out.Added, out.Tokens = added, emb.tokens()-before if err != nil { return out, err @@ -295,7 +316,7 @@ const embedWorkers = 4 // embedAll embeds pts in batches, a few at once, and keeps each batch as // it comes; it says how many were kept. -func embedAll(ctx context.Context, emb embedder, s store, key string, pts []point) (int, error) { +func embedAll(ctx context.Context, emb embedder, s store, key string, pts []point, onCall func(int64)) (int, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() batches := make(chan []point) @@ -320,7 +341,7 @@ func embedAll(ctx context.Context, emb embedder, s store, key string, pts []poin for j, pt := range part { texts[j] = header(pt) + pt.text } - vecs, err := emb.embed(ctx, texts, false) + vecs, err := emb.embed(ctx, texts, false, onCall) if err == nil { for j := range part { part[j].vec = vecs[j] @@ -428,7 +449,7 @@ func (e *Engine) Search(ctx context.Context, dir, query string, k int, scope []s if len(scope) > 0 { want = k * 12 } - q, err := emb.embed(ctx, []string{query}, true) + q, err := emb.embed(ctx, []string{query}, true, e.count(dir)) if err != nil { return nil, err } @@ -467,6 +488,57 @@ func (e *Engine) Search(ctx context.Context, dir, query string, k int, scope []s return out, nil } +// Words finds query in the project in dir by its words alone, its results +// marked as such: what a search falls back to when the embedder fails. +func (e *Engine) Words(ctx context.Context, dir, query string, k int, scope []string) ([]proto.RAGHit, error) { + if k <= 0 { + k = 5 + } + st := e.settings.Get() + switch st.Embedder { + case proto.RAGVoyage, proto.RAGOpenAI, proto.RAGTEI: + default: + return nil, ErrNotReady + } + // No embedder here: a search by words alone needs none, so it still + // answers when the embedder's key or address is what fails. + s, err := e.storeOf(ctx, st) + if err != nil { + return nil, err + } + defer s.close() + key := indexKey(dir, st) + found, err := s.keyword(ctx, key, query, k*4) + if err != nil { + return nil, err + } + if len(found) == 0 { + return nil, nil + } + ids := make([]string, len(found)) + for i, f := range found { + ids[i] = f.id + } + pts, err := s.get(ctx, key, ids) + if err != nil { + return nil, err + } + var out []proto.RAGHit + for _, f := range found { + pt, ok := pts[f.id] + if !ok || len(scope) > 0 && !slices.Contains(scope, pt.source) { + continue + } + h := hitOf(pt, 0) + h.Words = true + out = append(out, h) + if len(out) == k { + break + } + } + return out, nil +} + // hitOf is point p as a result, score its cosine with the query. func hitOf(p point, score float64) proto.RAGHit { text := p.text diff --git a/core/rag/rag_test.go b/core/rag/rag_test.go index c216cb2d..254e3cb0 100644 --- a/core/rag/rag_test.go +++ b/core/rag/rag_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "sync/atomic" "testing" "unicode/utf8" @@ -138,6 +139,48 @@ func indexAndSearch(t *testing.T, store string) { } } +// Every request to the embedder is counted, the search's query too: an +// index's and a prompt's, told apart by the project they were about. +func TestEveryEmbedderCallIsCounted(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "invoice.go"), + []byte("package shop\n\n// Total sums an invoice's lines with tax.\nfunc Total(tax float64) float64 {\n\treturn 0\n}\n"), 0o644) + if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + t.Skipf("git: %s", out) + } + home := t.TempDir() + st := OpenSettings(home) + if err := st.Set(proto.RAGSettings{Embedder: proto.RAGOpenAI, Model: "fake", BaseURL: fakeEmbeddings(t), Dim: 128}); err != nil { + t.Fatal(err) + } + e := NewEngine(home, st) + var mu sync.Mutex + counted := map[string]int64{} + e.OnEmbed = func(project string, n int64) { + mu.Lock() + counted[project] += n + mu.Unlock() + } + if _, err := e.Index(context.Background(), dir, proto.RAGProject{On: true}, nil); err != nil { + t.Fatal(err) + } + mu.Lock() + indexed := counted[dir] + mu.Unlock() + if indexed <= 0 { + t.Fatalf("the index counted %d tokens", indexed) + } + if _, err := e.Search(context.Background(), dir, "invoice tax total", 3, nil); err != nil { + t.Fatal(err) + } + mu.Lock() + after := counted[dir] + mu.Unlock() + if after <= indexed { + t.Fatalf("the query wasn't counted: %d after the index, %d after a search", indexed, after) + } +} + // With no embedder set nothing is indexed. func TestNotReady(t *testing.T) { home := t.TempDir() @@ -242,7 +285,7 @@ func TestEmbedBatchesBySize(t *testing.T) { for i := range texts { texts[i] = strings.Repeat("x", 10000) } - vecs, err := h.embed(t.Context(), texts, false) + vecs, err := h.embed(t.Context(), texts, false, nil) if err != nil || len(vecs) != len(texts) { t.Fatalf("%d vectors: %v", len(vecs), err) } diff --git a/core/session/scrollback_test.go b/core/session/scrollback_test.go new file mode 100644 index 00000000..352336c1 --- /dev/null +++ b/core/session/scrollback_test.go @@ -0,0 +1,37 @@ +package session + +import ( + "runtime" + "testing" + "time" +) + +// A session keeps about Scrollback of what it prints, not the terminal +// library's 10 MiB default: the daemon's memory is mostly one scrollback a +// session (docs/performance.md). +func TestASessionsScrollbackIsBounded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + m := NewManager() + defer m.CloseAll() + // About 12 MB of text: more than the old default, five times the new one. + out := `awk 'BEGIN{for(i=0;i<300000;i++) printf "%040d\n", i}'` + s, err := m.Create(Spec{ID: "a", Command: []string{"sh", "-c", out}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Minute) + for !s.Info().Exited { + if time.Now().After(deadline) { + t.Fatal("the program didn't finish") + } + time.Sleep(50 * time.Millisecond) + } + // The text is kept in VT sequences, a little longer than what was printed: + // 6 MB leaves that room and is still half what the old 10 MiB default of + // the terminal library would have kept. + if got := len(s.History()); got > 6<<20 { + t.Fatalf("kept %d bytes of history, for %d of text at most", got, Scrollback) + } +} diff --git a/core/session/session.go b/core/session/session.go index 29c37a93..5615dd84 100644 --- a/core/session/session.go +++ b/core/session/session.go @@ -27,8 +27,11 @@ var ErrNotFound = errors.New("session: not found") // ErrReplaced is returned when a session changed while being replaced. var ErrReplaced = errors.New("session: it changed meanwhile") -// Scrollback is how many bytes of scrollback each session keeps. -const Scrollback = 10 << 20 +// Scrollback is how many bytes of scrollback each session keeps: a fifth of +// what it did, as the daemon's own memory is mostly this, one scrollback a +// session (docs/performance.md). A long build's log is cut sooner; an agent's +// chat isn't affected, being read from its transcript. +const Scrollback = 2 << 20 // A session is active while its screen changed within activeFor; the // screen is checked every activityPeriod. Changes within echoFor of the diff --git a/core/session/work.go b/core/session/work.go index 9afe9ac7..f28b384d 100644 --- a/core/session/work.go +++ b/core/session/work.go @@ -112,6 +112,15 @@ func (m *Manager) SetConversation(s *Session, id string) { } } +// NewConversation says session s's agent left its conversation for a new one, not known yet: +// what its folder held before now isn't its own. +func (m *Manager) NewConversation(s *Session) { + s.mu.Lock() + s.meta.Conversation, s.meta.ConvoSince = "", time.Now() + s.mu.Unlock() + m.notify(s) +} + // SetNote sets what session s is at, "" to clear it. func (m *Manager) SetNote(s *Session, note string) { note = strings.TrimSpace(note) diff --git a/core/tokens/budget.go b/core/tokens/budget.go index a5e51f3d..6befcd9e 100644 --- a/core/tokens/budget.go +++ b/core/tokens/budget.go @@ -25,16 +25,21 @@ func PeriodStart(period string, now time.Time) time.Time { // applies reports whether call c counts against budget b: enowX's own use // never does. func applies(b proto.TokenBudget, c Call) bool { - if c.Own != "" { + return appliesRow(b, row{agent: c.Agent, project: c.Project, config: c.Config, own: c.Own}) +} + +// appliesRow is applies for a row of a summary. +func appliesRow(b proto.TokenBudget, r row) bool { + if r.own != "" { return false } switch b.Scope { case proto.BudgetAgent: - return c.Agent == b.Target + return r.agent == b.Target case proto.BudgetConfig: - return c.Config == b.Target + return r.config == b.Target case proto.BudgetProject: - return c.Project == b.Target + return r.project == b.Target } return true } @@ -44,9 +49,9 @@ func (s *Store) Budgets(budgets []proto.TokenBudget, prices *Prices, now time.Ti out := make([]proto.TokenBudgetState, 0, len(budgets)) for _, b := range budgets { st := proto.TokenBudgetState{TokenBudget: b, Since: PeriodStart(b.Period, now)} - for _, c := range s.Calls(st.Since, now.Add(time.Minute)) { - if applies(b, c) { - st.Used.Add(c.Counts(prices)) + for _, r := range s.rows(st.Since, now.Add(time.Minute), prices) { + if appliesRow(b, r) { + st.Used.Add(r.counts) } } if b.Tokens > 0 { diff --git a/core/tokens/prices.go b/core/tokens/prices.go index 1a74411d..96bb5d30 100644 --- a/core/tokens/prices.go +++ b/core/tokens/prices.go @@ -4,7 +4,9 @@ import ( "cmp" _ "embed" "encoding/json" + "hash/fnv" "regexp" + "strconv" "strings" "github.com/enowx/enowx/proto" @@ -41,6 +43,19 @@ func (p *Prices) All() []proto.TokenPrice { return append(append([]proto.TokenPrice{}, p.own...), p.shipped...) } +// Key names the table: the user's prices, the shipped ones being the same +// always. A day summed with one table is summed again with another. A nil +// table is "", as it prices nothing. +func (p *Prices) Key() string { + if p == nil { + return "" + } + data, _ := json.Marshal(p.own) + h := fnv.New64a() + h.Write(data) + return strconv.FormatUint(h.Sum64(), 16) +} + // Cost is what call c cost at its model's price, and whether one was // found. A local model's is free. func (p *Prices) Cost(c Call) (float64, bool) { diff --git a/core/tokens/recount.go b/core/tokens/recount.go index 3e78ff6d..5ddbc850 100644 --- a/core/tokens/recount.go +++ b/core/tokens/recount.go @@ -129,7 +129,7 @@ func (s *Store) Recount(fresh []Call) error { old := map[string]*Call{} var order []string for _, d := range dayNames { - for _, c := range s.loadLocked(d).calls { + for _, c := range s.rawLocked(d) { if k := old[c.ID]; k != nil { k.grow(c) continue @@ -198,7 +198,10 @@ func (s *Store) Recount(fresh []Call) error { return fmt.Errorf("tokens: write %s: %w", d, err) } } - s.days, s.where = map[string]*dayCalls{}, nil + // The day files are new: what was read of them, and where each ID was, is + // out of date. + s.days = map[string]*dayCalls{} + s.where, s.pruned = nil, "" r := recounted{Version: RecountVersion} for d := range approximate { r.Approximate = append(r.Approximate, d) diff --git a/core/tokens/rollup.go b/core/tokens/rollup.go index 08234e90..909e2981 100644 --- a/core/tokens/rollup.go +++ b/core/tokens/rollup.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "slices" + "strings" "time" "github.com/enowx/enowx/core/envsync" @@ -39,8 +40,7 @@ func (s *Store) Rollup(now time.Time, prices *Prices) error { } for _, d := range old { s.mu.Lock() - dc := s.loadLocked(d) - calls := slices.Clone(dc.calls) + calls := slices.Clone(s.rawLocked(d)) s.mu.Unlock() all = append(all, DayRows(calls, prices)...) } @@ -60,33 +60,36 @@ func (s *Store) Rollup(now time.Time, prices *Prices) error { return nil } +// DayRows is the store's use in [since, until) per day, agent, model, project, +// config and own use: what a host syncs to the others. +func (s *Store) DayRows(since, until time.Time, prices *Prices) []proto.TokenDayRow { + return dayRowsOf(s.rows(since, until, prices)) +} + // DayRows sums calls per day, agent, model, project, config and own use. func DayRows(calls []Call, prices *Prices) []proto.TokenDayRow { + return dayRowsOf(rowsOf(calls, prices)) +} + +// dayRowsOf sums rows per day, agent, model, project, config and own use. +func dayRowsOf(rows []row) []proto.TokenDayRow { type key struct{ day, agent, model, project, config, own string } sums := map[key]*proto.TokenDayRow{} var order []key - for _, c := range calls { - k := key{day(c.Time), c.Agent, c.Model, c.Project, c.Config, c.Own} - r := sums[k] - if r == nil { - r = &proto.TokenDayRow{Day: k.day, Agent: k.agent, Model: k.model, Project: k.project, Config: k.config, Own: k.own} - sums[k] = r + for _, r := range rows { + k := key{r.day, r.agent, r.model, r.project, r.config, r.own} + row := sums[k] + if row == nil { + row = &proto.TokenDayRow{Day: k.day, Agent: k.agent, Model: k.model, Project: k.project, Config: k.config, Own: k.own} + sums[k] = row order = append(order, k) } - r.Add(c.Counts(prices)) + row.Add(r.counts) } out := make([]proto.TokenDayRow, 0, len(order)) for _, k := range order { out = append(out, *sums[k]) } - slices.SortStableFunc(out, func(a, b proto.TokenDayRow) int { - if a.Day < b.Day { - return -1 - } - if a.Day > b.Day { - return 1 - } - return 0 - }) + slices.SortStableFunc(out, func(a, b proto.TokenDayRow) int { return strings.Compare(a.Day, b.Day) }) return out } diff --git a/core/tokens/store.go b/core/tokens/store.go index 5f1f62f0..4a30a208 100644 --- a/core/tokens/store.go +++ b/core/tokens/store.go @@ -17,6 +17,21 @@ import ( // a day's totals per agent, model, project and config. const KeepDays = 366 +// hotDays is how many of the latest days keep their calls in memory: the ones +// a scan writes to, and the ones a summary reads again and again. Older days +// are kept summed by everything a summary splits by (day, session, agent, +// model, project, config, account, layer, own use), which is a few dozen rows +// a day rather than thousands of calls: a year of them costs megabytes and not +// hundreds (docs/performance.md). +const hotDays = 2 + +// whereDays is how far back a call is remembered by its ID, so that one kept +// again on another day (a resumed or copied conversation's) counts once. A +// call older than that, written again on a day of its own, is counted once +// more, and grows it to the same figures: the record of every ID a year long +// would cost more than that does. +const whereDays = 40 + // Store keeps a host's calls in dir, a JSON lines file a day // (2006-01-02.jsonl), and the rolled-up days in rollups.json. Safe from // any goroutine; mu guards the cache of days read. @@ -25,15 +40,52 @@ type Store struct { // Now is the time calls are kept up to KeepDays before (tests set it). Now func() time.Time - mu sync.Mutex + mu sync.Mutex + // days is what was read of each day: its calls while it is one of the + // last hotDays, and them summed once it isn't. days map[string]*dayCalls - // where is the day each kept ID is in, by the ID's hash, built once from the day - // files on the first Add and kept small: a call seen again on another day, a - // resumed or copied conversation's, is the same call. Nil until then. + // where is the day each kept ID is in, by the ID's hash, built from the + // day files on the first Add. Nil until then. where map[uint64]int32 + // pruned is the day where was last cut back to whereDays. + pruned string +} + +// dayCalls is a day as the store keeps it: its calls and where each ID is +// among them while it is hot, and them summed once it isn't. +type dayCalls struct { + calls []Call + at map[string]int + // rows is the day summed by everything a summary splits by, kept for a + // day past hotDays in place of its calls. + rows []row + // priced is the price table rows were summed with (Prices.Key). + priced string +} + +// grow takes what later record o of call c says it counted: the largest of each +// field, as an agent may write one call over several records whose counts grow as it +// streams. It says whether c changed. +func (c *Call) grow(o Call) bool { + before := *c + c.Input, c.CacheRead, c.CacheWrite = max(c.Input, o.Input), max(c.CacheRead, o.CacheRead), max(c.CacheWrite, o.CacheWrite) + c.Output, c.Reasoning, c.Cost = max(c.Output, o.Output), max(c.Reasoning, o.Reasoning), max(c.Cost, o.Cost) + c.CacheWrite1h = max(c.CacheWrite1h, o.CacheWrite1h) + return *c != before } -// idHash is an ID as where keeps it. +// Open opens the store in dir, made when needed. +func Open(dir string) *Store { return &Store{dir: dir, Now: time.Now, days: map[string]*dayCalls{}} } + +// Dir is where the store keeps its files. +func (s *Store) Dir() string { return s.dir } + +func (s *Store) file(d string) string { return filepath.Join(s.dir, d+".jsonl") } + +// hot says day d keeps its calls in memory: one of the last hotDays. +func (s *Store) hot(d string) bool { return d >= day(s.Now().AddDate(0, 0, -hotDays)) } + +// idHash is an ID as where keeps it: eight bytes an ID rather than the ID. func idHash(id string) uint64 { h := fnv.New64a() h.Write([]byte(id)) @@ -51,14 +103,15 @@ func dayNumber(d string) int32 { func dayOfNumber(n int32) string { return time.Unix(int64(n)*86400, 0).UTC().Format(time.DateOnly) } -// loadWhereLocked builds where from the day files, reading only each line's ID, and -// leaves the days out of the cache. mu is held. +// loadWhereLocked builds where from the day files of the last whereDays, +// reading each line's ID only. mu is held. func (s *Store) loadWhereLocked() { s.where = map[uint64]int32{} + oldest := day(s.Now().AddDate(0, 0, -whereDays)) entries, _ := os.ReadDir(s.dir) for _, e := range entries { d, ok := strings.CutSuffix(e.Name(), ".jsonl") - if !ok || len(d) != len(time.DateOnly) { + if !ok || len(d) != len(time.DateOnly) || d < oldest { continue } f, err := os.Open(filepath.Join(s.dir, e.Name())) @@ -73,65 +126,122 @@ func (s *Store) loadWhereLocked() { ID string `json:"id"` } if json.Unmarshal(sc.Bytes(), &line) == nil && line.ID != "" { - if _, seen := s.where[idHash(line.ID)]; !seen { - s.where[idHash(line.ID)] = n + h := idHash(line.ID) + if _, seen := s.where[h]; !seen { + s.where[h] = n } } } f.Close() } + s.pruned = day(s.Now()) } -// dayCalls is a day's calls as read, and where each ID is among them. -type dayCalls struct { - calls []Call - at map[string]int +// pruneWhereLocked cuts where back to the last whereDays, once a day. mu is +// held. +func (s *Store) pruneWhereLocked(today string) { + s.pruned = today + oldest := dayNumber(day(s.Now().AddDate(0, 0, -whereDays))) + for h, n := range s.where { + if n < oldest { + delete(s.where, h) + } + } } -// grow takes what later record o of call c says it counted: the largest of each -// field, as an agent may write one call over several records whose counts grow as it -// streams. It says whether c changed. -func (c *Call) grow(o Call) bool { - before := *c - c.Input, c.CacheRead, c.CacheWrite = max(c.Input, o.Input), max(c.CacheRead, o.CacheRead), max(c.CacheWrite, o.CacheWrite) - c.Output, c.Reasoning, c.Cost = max(c.Output, o.Output), max(c.Reasoning, o.Reasoning), max(c.Cost, o.Cost) - c.CacheWrite1h = max(c.CacheWrite1h, o.CacheWrite1h) - return *c != before +// readDayCalls is the calls kept in file, each ID once, a later line +// correcting an earlier one. +func readDayCalls(file string) *dayCalls { + dc := &dayCalls{at: map[string]int{}} + f, err := os.Open(file) + if err != nil { + return dc + } + defer f.Close() + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64<<10), 1<<20) + for sc.Scan() { + var c Call + if json.Unmarshal(sc.Bytes(), &c) != nil || c.ID == "" { + continue + } + if i, seen := dc.at[c.ID]; seen { + dc.calls[i].grow(c) // a later line corrects an earlier one + continue + } + dc.at[c.ID] = len(dc.calls) + dc.calls = append(dc.calls, c) + } + return dc } -// Open opens the store in dir, made when needed. -func Open(dir string) *Store { return &Store{dir: dir, Now: time.Now, days: map[string]*dayCalls{}} } - -// Dir is where the store keeps its files. -func (s *Store) Dir() string { return s.dir } +// dayLocked is day d as the cache has it: its calls while it is hot, its rows +// once it isn't. mu is held. +func (s *Store) dayLocked(d string, prices *Prices) *dayCalls { + key := prices.Key() + if dc := s.days[d]; dc != nil && (dc.calls != nil || dc.priced == key) { + return dc + } + dc := readDayCalls(s.file(d)) + dc.priced = key + if !s.hot(d) { + dc.rows, dc.calls, dc.at = rowsOf(dc.calls, prices), nil, nil + } + s.days[d] = dc + return dc +} -func (s *Store) file(d string) string { return filepath.Join(s.dir, d+".jsonl") } +// rawLocked is day d's calls: from the cache while it is hot, read and let go +// once it isn't. mu is held. +func (s *Store) rawLocked(d string) []Call { + if dc := s.days[d]; dc != nil && dc.calls != nil { + return dc.calls + } + dc := readDayCalls(s.file(d)) + if s.hot(d) { + s.days[d] = dc // a hot day is held, as a scan and a summary read it again + } + return dc.calls +} -// loadLocked reads day d into the cache. mu is held. -func (s *Store) loadLocked(d string) *dayCalls { - if dc := s.days[d]; dc != nil { +// addLocked is day d as Add needs it: its calls and where each ID is among +// them, read from its file when the cache has only its rows, as a call may be +// corrected. mu is held. +func (s *Store) addLocked(d string) *dayCalls { + if dc := s.days[d]; dc != nil && dc.calls != nil { return dc } - dc := &dayCalls{at: map[string]int{}} - if f, err := os.Open(s.file(d)); err == nil { - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 64<<10), 1<<20) - for sc.Scan() { - var c Call - if json.Unmarshal(sc.Bytes(), &c) != nil || c.ID == "" { - continue - } - if i, seen := dc.at[c.ID]; seen { - dc.calls[i].grow(c) // a later line corrects an earlier one - continue + dc := readDayCalls(s.file(d)) + s.days[d] = dc + return dc +} + +// rows is the store's use in [since, until) summed by everything a summary +// splits by. A day either end of the range falls in is read call by call, as +// the range cuts it; the days between are kept summed. +func (s *Store) rows(since, until time.Time, prices *Prices) []row { + from, to := day(since), day(until) + var out []row + for _, d := range s.Days(since, until) { + s.mu.Lock() + var calls []Call + if d == from || d == to { + calls = s.rawLocked(d) // its file, when a cold day holds only its rows + } else if dc := s.dayLocked(d, prices); dc.calls == nil { + out = append(out, dc.rows...) + s.mu.Unlock() + continue + } else { + calls = dc.calls + } + for _, c := range calls { + if !c.Time.Before(since) && c.Time.Before(until) { + out = append(out, rowOf(c, prices)) } - dc.at[c.ID] = len(dc.calls) - dc.calls = append(dc.calls, c) } - f.Close() + s.mu.Unlock() } - s.days[d] = dc - return dc + return out } // Add keeps the calls not kept yet (by ID, on whichever day each was kept), corrects @@ -142,10 +252,18 @@ func (s *Store) Add(calls []Call) ([]Call, error) { defer s.mu.Unlock() if s.where == nil { s.loadWhereLocked() + } else if today := day(s.Now()); today != s.pruned { + s.pruneWhereLocked(today) } oldest := s.Now().AddDate(0, 0, -KeepDays) byDay := map[string][]Call{} + var order []string var added []Call + queue := func(d string) { + if _, queued := byDay[d]; !queued { + order = append(order, d) + } + } for _, c := range calls { if c.ID == "" || c.Empty() || c.Time.Before(oldest) { continue @@ -153,18 +271,19 @@ func (s *Store) Add(calls []Call) ([]Call, error) { d := day(c.Time) h := idHash(c.ID) if n, known := s.where[h]; known { - // A hash can collide, and a day can be rolled up: its day must hold the ID. + // A hash can collide, and a day can be summed: its day must hold the ID. if k := dayOfNumber(n); k != d { - if _, here := s.loadLocked(k).at[c.ID]; here { + if slices.ContainsFunc(s.rawLocked(k), func(x Call) bool { return x.ID == c.ID }) { d = k } } } else { s.where[h] = dayNumber(d) } - dc := s.loadLocked(d) + dc := s.addLocked(d) if i, seen := dc.at[c.ID]; seen { if dc.calls[i].grow(c) { + queue(d) byDay[d] = append(byDay[d], dc.calls[i]) added = append(added, dc.calls[i]) } @@ -172,42 +291,58 @@ func (s *Store) Add(calls []Call) ([]Call, error) { } dc.at[c.ID] = len(dc.calls) dc.calls = append(dc.calls, c) + queue(d) byDay[d] = append(byDay[d], c) added = append(added, c) } + // A day past hotDays was read to be added to, and isn't held: it goes now, + // whether it was written or not, and the next read sums it afresh. + for d := range s.days { + if !s.hot(d) { + delete(s.days, d) + } + } if len(byDay) == 0 { return nil, nil } if err := os.MkdirAll(s.dir, 0o700); err != nil { return added, fmt.Errorf("tokens: %w", err) } - for d, list := range byDay { - var b strings.Builder - for _, c := range list { - line, _ := json.Marshal(c) - b.Write(line) - b.WriteByte('\n') - } - f, err := os.OpenFile(s.file(d), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return added, fmt.Errorf("tokens: write %s: %w", d, err) - } - _, err = f.WriteString(b.String()) - f.Close() - if err != nil { - return added, fmt.Errorf("tokens: write %s: %w", d, err) + for _, d := range order { + if err := appendCalls(s.file(d), byDay[d]); err != nil { + return added, err } } return added, nil } -// Calls returns the calls of [since, until), oldest day first. +// appendCalls writes calls to file, one JSON line each. +func appendCalls(file string, calls []Call) error { + var b strings.Builder + for _, c := range calls { + line, _ := json.Marshal(c) + b.Write(line) + b.WriteByte('\n') + } + f, err := os.OpenFile(file, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("tokens: write %s: %w", filepath.Base(file), err) + } + _, err = f.WriteString(b.String()) + f.Close() + if err != nil { + return fmt.Errorf("tokens: write %s: %w", filepath.Base(file), err) + } + return nil +} + +// Calls returns the calls of [since, until), oldest day first. A day past +// hotDays is read from its file and let go, not held. func (s *Store) Calls(since, until time.Time) []Call { var out []Call for _, d := range s.Days(since, until) { s.mu.Lock() - dc := s.loadLocked(d) - for _, c := range dc.calls { + for _, c := range s.rawLocked(d) { if !c.Time.Before(since) && c.Time.Before(until) { out = append(out, c) } @@ -232,14 +367,13 @@ func (s *Store) Days(since, until time.Time) []string { return out } -// Forget drops the cached days other than the last few, to free memory -// once a long range was read. +// Forget drops the days whose calls aren't held, to free memory once a long +// range was read. func (s *Store) Forget() { s.mu.Lock() defer s.mu.Unlock() - keep := day(time.Now().AddDate(0, 0, -40)) for d := range s.days { - if d < keep { + if !s.hot(d) { delete(s.days, d) } } diff --git a/core/tokens/store_cold_day_test.go b/core/tokens/store_cold_day_test.go new file mode 100644 index 00000000..51dc56c6 --- /dev/null +++ b/core/tokens/store_cold_day_test.go @@ -0,0 +1,170 @@ +package tokens + +import ( + "math" + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +// A range that starts or ends partway through a day kept summed counts only +// the calls inside it: a day is taken whole only between the range's ends, +// and those two are read call by call, even once the day is summed. +func TestAColdDaysRangeIsExact(t *testing.T) { + d0 := time.Date(2026, 10, 26, 0, 0, 0, 0, time.Local) // ten days back: summed, not held + now := d0.AddDate(0, 0, 10).Add(12 * time.Hour) + s := Open(t.TempDir()) + s.Now = func() time.Time { return now } + calls := []Call{ + {ID: "a", Time: d0.Add(time.Hour), Agent: "claude", Input: 100, Layer: proto.TokenLayerRecords}, + {ID: "b", Time: d0.Add(2 * time.Hour), Agent: "claude", Input: 200, Layer: proto.TokenLayerRecords}, + {ID: "c", Time: d0.Add(3 * time.Hour), Agent: "claude", Input: 400, Layer: proto.TokenLayerRecords}, + } + if _, err := s.Add(calls); err != nil { + t.Fatal(err) + } + prices := NewPrices(nil) + // A range past the day on both ends keeps it summed. + wide := s.Summarize(proto.TokensQuery{Since: d0.AddDate(0, 0, -1), Until: d0.AddDate(0, 0, 2)}, prices, now) + if wide.Total.Input != 700 || wide.Total.Calls != 3 { + t.Fatalf("the day summed: %+v", wide.Total) + } + s.mu.Lock() + dc := s.days[day(d0)] + summed := dc != nil && dc.calls == nil && len(dc.rows) > 0 + s.mu.Unlock() + if !summed { + t.Fatal("the day was kept as calls, not summed") + } + + whole := s.Summarize(proto.TokensQuery{Since: d0, Until: d0.AddDate(0, 0, 1)}, prices, now) + if whole.Total.Input != 700 || whole.Total.Calls != 3 { + t.Fatalf("the whole day: %+v", whole.Total) + } + in := s.Summarize(proto.TokensQuery{Since: d0.Add(90 * time.Minute), Until: d0.Add(150 * time.Minute)}, prices, now) + if in.Total.Input != 200 || in.Total.Calls != 1 { + t.Fatalf("inside the range: %+v", in.Total) + } + after := s.Summarize(proto.TokensQuery{Since: d0.Add(150 * time.Minute), Until: d0.AddDate(0, 0, 1)}, prices, now) + if after.Total.Input != 400 || after.Total.Calls != 1 { + t.Fatalf("after the range: %+v", after.Total) + } +} + +// same sums says counts a and b are the same: the figures exactly, the costs to +// the last bit a float of this size can be trusted with, as sums added in a +// different order may land a rounding step apart. +func sameSums(a, b proto.TokenCounts) bool { + const eps = 1e-9 + return a.Input == b.Input && a.CacheRead == b.CacheRead && a.CacheWrite == b.CacheWrite && + a.Output == b.Output && a.Reasoning == b.Reasoning && a.Calls == b.Calls && + a.Unpriced == b.Unpriced && a.Estimated == b.Estimated && + math.Abs(a.Cost-b.Cost) < eps && math.Abs(a.Equivalent-b.Equivalent) < eps +} + +// Days summed once add up to exactly what their calls do, for every split a +// summary offers, for the day rows a host syncs and for a session's badge: +// the summing loses the calls, not a figure. +func TestSummedDaysAddUpToTheirCalls(t *testing.T) { + now := time.Now().Truncate(time.Hour) + s := Open(t.TempDir()) + s.Now = func() time.Time { return now } + prices := NewPrices([]proto.TokenPrice{{Model: "claude-opus-5-5", Input: 15, CacheRead: 1.5, CacheWrite: 18.75, Output: 75}}) + calls := []Call{ + {ID: "a", Time: now.AddDate(0, 0, -12).Add(time.Hour), Agent: "claude", Session: "s1", Model: "claude-opus-5-5", Project: "/p", Config: "work", Account: "me", Input: 1000, CacheRead: 2000, Output: 300, Layer: proto.TokenLayerRecords}, + {ID: "b", Time: now.AddDate(0, 0, -12).Add(2 * time.Hour), Agent: "codex", Session: "s2", Model: "gpt-6-luna", Project: "/q", Input: 4000, CacheWrite: 500, Output: 600, Layer: proto.TokenLayerRecords}, + {ID: "c", Time: now.AddDate(0, 0, -6).Add(3 * time.Hour), Agent: "claude", Session: "s1", Model: "claude-opus-5-5", Project: "/p", Config: "work", Account: "me", Input: 7000, Output: 800, Plan: true, Layer: proto.TokenLayerRecords}, + {ID: "d", Time: now.AddDate(0, 0, -1).Add(4 * time.Hour), Agent: "pi", Session: "s3", Model: "local-model", Project: "/q", Provider: "local", Input: 90, Output: 10, Layer: proto.TokenLayerTraffic}, + {ID: "e", Time: now.Add(-2 * time.Hour), Agent: "claude", Session: "s1", Model: "claude-opus-5-5", Project: "/p", Config: "work", Account: "me", Input: 11, Output: 22, Layer: proto.TokenLayerRecords}, + {ID: "own", Time: now.Add(-3 * time.Hour), Own: proto.TokensOwnJudge, Model: "small-model", Input: 33, Output: 44, Layer: proto.TokenLayerTraffic}, + } + if _, err := s.Add(calls); err != nil { + t.Fatal(err) + } + since, until := now.AddDate(0, 0, -30), now.Add(time.Minute) + + // What the sums should be, straight from the calls. + total := proto.TokenCounts{} + own := proto.TokenCounts{} + by := map[string]map[string]proto.TokenCounts{} + days := map[string]proto.TokenCounts{} + dayRows := map[[6]string]proto.TokenCounts{} + sessions := map[string]proto.TokenCounts{} + splits := []string{proto.TokensByAgent, proto.TokensByModel, proto.TokensByProject, proto.TokensBySession, proto.TokensByConfig, proto.TokensByAccount, proto.TokensByLayer} + addTo := func(m map[string]proto.TokenCounts, key string, n proto.TokenCounts) { + got := m[key] + got.Add(n) + m[key] = got + } + for _, c := range s.Calls(since, until) { + n := c.Counts(prices) + // The day rows a host syncs keep enowX's own use too, the summary apart. + k := [6]string{day(c.Time), c.Agent, c.Model, c.Project, c.Config, c.Own} + got := dayRows[k] + got.Add(n) + dayRows[k] = got + if c.Own != "" { + own.Add(n) + continue + } + total.Add(n) + addTo(days, day(c.Time), n) + if c.Session != "" { + addTo(sessions, c.Session, n) + } + r := rowOf(c, prices) + for _, split := range splits { + if by[split] == nil { + by[split] = map[string]proto.TokenCounts{} + } + addTo(by[split], r.key(split), n) + } + } + + for _, split := range splits { + sum := s.Summarize(proto.TokensQuery{Since: since, Until: until, By: split}, prices, now) + if !sameSums(sum.Total, total) { + t.Fatalf("%s: total %+v, from the calls %+v", split, sum.Total, total) + } + if len(sum.Groups) != len(by[split]) { + t.Fatalf("%s: %d groups, from the calls %d: %+v", split, len(sum.Groups), len(by[split]), sum.Groups) + } + for _, g := range sum.Groups { + if want := by[split][g.Key]; !sameSums(g.TokenCounts, want) { + t.Fatalf("%s: %q is %+v, from the calls %+v", split, g.Key, g.TokenCounts, want) + } + } + if len(sum.Days) != len(days) { + t.Fatalf("%s: %d days, from the calls %d", split, len(sum.Days), len(days)) + } + for _, d := range sum.Days { + if want := days[d.Day]; !sameSums(d.TokenCounts, want) { + t.Fatalf("%s: day %s is %+v, from the calls %+v", split, d.Day, d.TokenCounts, want) + } + } + } + if o := s.Summarize(proto.TokensQuery{Since: since, Until: until}, prices, now); !sameSums(o.Own, own) { + t.Fatalf("own use %+v, from the calls %+v", o.Own, own) + } + + rows := s.DayRows(since, until, prices) + if len(rows) != len(dayRows) { + t.Fatalf("%d day rows, from the calls %d: %+v", len(rows), len(dayRows), rows) + } + for _, r := range rows { + k := [6]string{r.Day, r.Agent, r.Model, r.Project, r.Config, r.Own} + if want := dayRows[k]; !sameSums(r.TokenCounts, want) { + t.Fatalf("day row %v is %+v, from the calls %+v", k, r.TokenCounts, want) + } + } + totals := s.SessionTotals(since, prices) + if len(totals) != len(sessions) { + t.Fatalf("%d sessions, from the calls %d: %+v", len(totals), len(sessions), totals) + } + for _, st := range totals { + if want := sessions[st.Session]; !sameSums(st.TokenCounts, want) { + t.Fatalf("session %q is %+v, from the calls %+v", st.Session, st.TokenCounts, want) + } + } +} diff --git a/core/tokens/store_memory_test.go b/core/tokens/store_memory_test.go new file mode 100644 index 00000000..99fb058b --- /dev/null +++ b/core/tokens/store_memory_test.go @@ -0,0 +1,110 @@ +package tokens + +import ( + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +// A day past hotDays is kept summed: its calls aren't held, and what it adds +// up to is the same as when they were. +func TestOldDaysAreSummedNotHeld(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -10) + s := Open(dir) + s.Now = func() time.Time { return now } + calls := []Call{ + {ID: "claude:a", Time: old, Agent: "claude", Project: "/p", Model: "claude-opus-5-5", Input: 1000, Output: 100, Layer: proto.TokenLayerRecords}, + {ID: "claude:b", Time: old.Add(time.Hour), Agent: "claude", Project: "/p", Model: "claude-opus-5-5", Input: 2000, Output: 200, Layer: proto.TokenLayerRecords}, + {ID: "codex:c", Time: old.Add(2 * time.Hour), Agent: "codex", Project: "/p", Model: "gpt-6-luna", Input: 500, Layer: proto.TokenLayerRecords}, + } + if _, err := s.Add(calls); err != nil { + t.Fatal(err) + } + day := day(old) + s.mu.Lock() + dc := s.days[day] + held := dc != nil && dc.calls != nil + s.mu.Unlock() + if held { + t.Fatal("a day ten days old holds its calls after Add") + } + + sum := s.Summarize(proto.TokensQuery{By: proto.TokensByAgent, Until: now}, NewPrices(nil), now) + if sum.Total.Input != 3500 || sum.Total.Output != 300 || sum.Total.Calls != 3 { + t.Fatalf("total: %+v", sum.Total) + } + if len(sum.Groups) != 2 || sum.Groups[0].Key != "claude" || sum.Groups[0].Input != 3000 { + t.Fatalf("groups: %+v", sum.Groups) + } + if len(sum.Days) != 1 || sum.Days[0].Day != day { + t.Fatalf("days: %+v", sum.Days) + } + + s.mu.Lock() + dc = s.days[day] + held = dc != nil && dc.calls != nil + summed := dc != nil && len(dc.rows) > 0 + s.mu.Unlock() + if held { + t.Fatal("a day ten days old held its calls to be summed") + } + if !summed { + t.Fatal("a day ten days old wasn't summed") + } + + // What it adds up to doesn't change as the price table or the range does. + again := Open(dir) + again.Now = func() time.Time { return now } + second := again.Summarize(proto.TokensQuery{By: proto.TokensByProject, Until: now}, NewPrices([]proto.TokenPrice{{Model: "claude-opus-5-5", Input: 1, Output: 2}}), now) + if second.Total.Calls != 3 || second.Total.Input != 3500 { + t.Fatalf("reopened: %+v", second.Total) + } +} + +// The calls of the last days stay held, where a scan and a badge read them +// again and again. +func TestRecentDaysAreHeld(t *testing.T) { + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + s := Open(t.TempDir()) + s.Now = func() time.Time { return now } + at := now.Add(-2 * time.Hour) + if _, err := s.Add([]Call{{ID: "a", Time: at, Agent: "claude", Input: 10, Layer: proto.TokenLayerRecords}}); err != nil { + t.Fatal(err) + } + s.Summarize(proto.TokensQuery{Until: now}, NewPrices(nil), now) + s.mu.Lock() + dc := s.days[day(at)] + held := dc != nil && len(dc.calls) == 1 && dc.rows == nil + s.mu.Unlock() + if !held { + t.Fatal("today's calls aren't held") + } +} + +// A call on a day past hotDays still counts once when its conversation is +// resumed days later and the agent writes it again on today's file. +func TestACallCopiedToTodayStillCountsOnce(t *testing.T) { + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + dir := t.TempDir() + old := now.AddDate(0, 0, -10) + s := Open(dir) + s.Now = func() time.Time { return now } + first := Call{ID: "claude:m1", Time: old, Agent: "claude", Output: 10, Layer: proto.TokenLayerRecords} + if _, err := s.Add([]Call{first}); err != nil { + t.Fatal(err) + } + s = Open(dir) // a new run, where nothing is held + s.Now = func() time.Time { return now } + copied := first + copied.Time, copied.Output = now.Add(-time.Hour), 40 + if got, _ := s.Add([]Call{copied}); len(got) != 1 || got[0].Output != 40 { + t.Fatalf("grown: %+v", got) + } + sum := Open(dir).Summarize(proto.TokensQuery{Until: now.Add(time.Hour)}, NewPrices(nil), now) + if sum.Total.Calls != 1 || sum.Total.Output != 40 { + t.Fatalf("counted twice: %+v", sum.Total) + } +} diff --git a/core/tokens/summary.go b/core/tokens/summary.go index 8e404597..ecb83ccc 100644 --- a/core/tokens/summary.go +++ b/core/tokens/summary.go @@ -24,20 +24,49 @@ func Range(q proto.TokensQuery, now time.Time) (time.Time, time.Time) { return since, until } -// row is a call or a rolled-up day's row: what a summary is made of. +// row is a call or a summed day's row: what a summary is made of. type row struct { day, session, agent, model, project, config, account, layer, own string counts proto.TokenCounts } +// rowOf is call c as a row, priced by prices. +func rowOf(c Call, prices *Prices) row { + return row{day(c.Time), c.Session, c.Agent, c.Model, c.Project, c.Config, c.Account, c.Layer, c.Own, c.Counts(prices)} +} + +// rowsOf is calls summed by everything a summary splits by: one row a day, +// session, agent, model, project, config, account, layer and own use. A day +// past hotDays keeps a few dozen of them in place of thousands of calls, and +// every split stays exact. +func rowsOf(calls []Call, prices *Prices) []row { + type key struct{ day, session, agent, model, project, config, account, layer, own string } + sums := map[key]*row{} + var order []key + for _, c := range calls { + k := key{day(c.Time), c.Session, c.Agent, c.Model, c.Project, c.Config, c.Account, c.Layer, c.Own} + r := sums[k] + if r == nil { + one := rowOf(c, prices) + r = &one + sums[k] = r + order = append(order, k) + } else { + r.counts.Add(c.Counts(prices)) + } + } + out := make([]row, 0, len(order)) + for _, k := range order { + out = append(out, *sums[k]) + } + return out +} + // Summarize sums the store's use as q asks, priced by prices. host // labels the host split. func (s *Store) Summarize(q proto.TokensQuery, prices *Prices, now time.Time) proto.TokensSummary { since, until := Range(q, now) - var rows []row - for _, c := range s.Calls(since, until) { - rows = append(rows, row{day(c.Time), c.Session, c.Agent, c.Model, c.Project, c.Config, c.Account, c.Layer, c.Own, c.Counts(prices)}) - } + rows := s.rows(since, until, prices) for _, r := range s.RolledUp(since, until) { rows = append(rows, row{day: r.Day, agent: r.Agent, model: r.Model, project: r.Project, config: r.Config, own: r.Own, counts: r.TokenCounts}) } @@ -141,16 +170,16 @@ func sorted(m map[string]*proto.TokenGroup) []proto.TokenGroup { // SessionTotals sums each session's calls since since. func (s *Store) SessionTotals(since time.Time, prices *Prices) []proto.TokenSessionTotal { sums := map[string]*proto.TokenSessionTotal{} - for _, c := range s.Calls(since, time.Now().Add(time.Minute)) { - if c.Session == "" || c.Own != "" { + for _, r := range s.rows(since, time.Now().Add(time.Minute), prices) { + if r.session == "" || r.own != "" { continue } - t := sums[c.Session] + t := sums[r.session] if t == nil { - t = &proto.TokenSessionTotal{Session: c.Session} - sums[c.Session] = t + t = &proto.TokenSessionTotal{Session: r.session} + sums[r.session] = t } - t.Add(c.Counts(prices)) + t.Add(r.counts) } out := make([]proto.TokenSessionTotal, 0, len(sums)) for _, t := range sums { diff --git a/core/transcript/agents.go b/core/transcript/agents.go index c1816bfb..2fb69045 100644 --- a/core/transcript/agents.go +++ b/core/transcript/agents.go @@ -51,24 +51,54 @@ func Supports(agent string) bool { return ok } -// FindIn lists the conversations agent had in dir, newest change first: -// those in store when the agent kept them there (a session's own folder, -// Preset.SessionDir), else Find's. -func FindIn(agent, home, dir, store string) []Found { - if store != "" { - var out []Found - entries, _ := os.ReadDir(store) - for _, e := range entries { - if info, err := e.Info(); err == nil && !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") && !strings.HasPrefix(e.Name(), ".") { - out = append(out, Found{Path: filepath.Join(store, e.Name()), Changed: info.ModTime()}) - } +// Stored lists the conversations an agent kept in store, a session's own folder +// (Preset.SessionDir), newest change first; nil when it keeps none there. +func Stored(store string) []Found { + if store == "" { + return nil + } + var out []Found + entries, _ := os.ReadDir(store) + for _, e := range entries { + if info, err := e.Info(); err == nil && !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") && !strings.HasPrefix(e.Name(), ".") { + out = append(out, Found{Path: filepath.Join(store, e.Name()), Changed: info.ModTime()}) + } + } + slices.SortFunc(out, func(a, b Found) int { return b.Changed.Compare(a.Changed) }) + return out +} + +// Started is when the conversation at path began, as its first record's time says: +// Claude Code's and Codex's timestamp, OMP's and Claude Platform's. ok is false when no +// record says it (an older or a hand-written file), and the caller goes by the file's +// change instead. +func Started(agent, path string) (time.Time, bool) { + if !Supports(agent) { + return time.Time{}, false + } + f, err := os.Open(path) + if err != nil { + return time.Time{}, false + } + defer f.Close() + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64<<10), 16<<20) + for i := 0; i < 40 && sc.Scan(); i++ { + var rec struct { + Timestamp time.Time `json:"timestamp"` + Time time.Time `json:"time"` + } + if json.Unmarshal(sc.Bytes(), &rec) != nil { + continue + } + if !rec.Timestamp.IsZero() { + return rec.Timestamp, true } - if len(out) > 0 { - slices.SortFunc(out, func(a, b Found) int { return b.Changed.Compare(a.Changed) }) - return out + if !rec.Time.IsZero() { + return rec.Time, true } } - return Find(agent, home, dir) + return time.Time{}, false } // HomeOf is where agent keeps its files under home, "" for an agent with none known here. diff --git a/core/transcript/platform.go b/core/transcript/platform.go index 8e9a4480..2daf3576 100644 --- a/core/transcript/platform.go +++ b/core/transcript/platform.go @@ -18,7 +18,7 @@ import ( // Claude Platform, enowX's own agent, keeps a conversation in one file of JSON lines // (package record): what the chat shows as it happens, the streamed text included, so a // chat polling the file sees an answer grow. A session's file is in its --session-dir -// (FindIn); PlatformFiles finds those of the agent run by hand. See docs/chat-view.md. +// (Stored); PlatformFiles finds those of the agent run by hand. See docs/chat-view.md. // PlatformFiles returns the conversations Claude Platform kept in its default folder // for sessions run in dir, newest first. diff --git a/core/transcript/platform_test.go b/core/transcript/platform_test.go index d55b097e..1d45466c 100644 --- a/core/transcript/platform_test.go +++ b/core/transcript/platform_test.go @@ -225,14 +225,15 @@ func TestPlatformIsRegistered(t *testing.T) { here := filepath.Join(sessions, "c1.jsonl") appendLines(t, here, cpMeta) appendLines(t, filepath.Join(sessions, "other.jsonl"), `{"type":"meta","dir":"/elsewhere"}`+"\n") - found := FindIn(record.AgentID, home, "/repo", store) + found := Find(record.AgentID, home, "/repo") if len(found) != 1 || found[0].Path != here { t.Fatalf("default folder: %+v", found) } - // In a session: its own folder. + // In a session: its own folder, which the daemon reads instead of the default + // one (daemon/transcript.go). mine := filepath.Join(store, "c2.jsonl") appendLines(t, mine, cpMeta+cpFinal) - found = FindIn(record.AgentID, home, "/repo", store) + found = Stored(store) if len(found) != 1 || found[0].Path != mine { t.Fatalf("store: %+v", found) } diff --git a/core/transcript/settings.go b/core/transcript/settings.go index 8569b983..98ebc0e7 100644 --- a/core/transcript/settings.go +++ b/core/transcript/settings.go @@ -17,11 +17,18 @@ type Settings struct { } // LastSettings is what agent last said in its transcript at path, read from its end. -func LastSettings(agent, path string) Settings { +func LastSettings(agent, path string) Settings { return LastSettingsSince(agent, path, 0) } + +// LastSettingsSince is LastSettings of what the transcript says past its first floor bytes: a +// session switched to another model leaves what came before behind. +func LastSettingsSince(agent, path string, floor int64) Settings { info, err := os.Stat(path) if err != nil { return Settings{} } + if floor > info.Size() { + floor = 0 // it started over + } var out Settings for _, window := range settingsWindows { r := Open(agent, path) @@ -29,12 +36,12 @@ func LastSettings(agent, path string) Settings { return Settings{} } // A line cut by the window's start doesn't parse, and is skipped. - r.offset = max(info.Size()-window, 0) + r.offset = max(info.Size()-window, floor) if err := r.Update(); err != nil { return Settings{} } out = Settings{Model: r.Model, Effort: r.Effort, Mode: r.Mode, ModelChosen: r.ModelChosen} - if out.Model != "" || r.offset-window <= 0 && window >= info.Size() { + if out.Model != "" || window >= info.Size()-floor { return out } } diff --git a/daemon/accounts_switch.go b/daemon/accounts_switch.go index d29102f6..21f89d7f 100644 --- a/daemon/accounts_switch.go +++ b/daemon/accounts_switch.go @@ -84,6 +84,8 @@ func (s *Server) switchAgent(p proto.AccountSwitchParams) (proto.SessionInfo, er s.reapSession(info.ID) } meta.Agent, meta.Account, meta.AccountFirst, meta.Conversation, meta.Config = preset.ID, acc, "", "", run.sessionInfo() + meta.ConvoSince = time.Now() + s.transcripts.forget(info.ID) // what it followed is the other agent's meta.Restart, meta.Relaunch, meta.SkipPermissions = l.restart, l.relaunch, l.skip keepBypass(&meta) meta.Rules = s.rulesAtStart(meta, info.Dir, true) @@ -91,8 +93,11 @@ func (s *Server) switchAgent(p proto.AccountSwitchParams) (proto.SessionInfo, er if err != nil { return proto.SessionInfo{}, err } + // Its key, as a session starting again takes it: another agent's lease isn't its own. + vaultEnv, creds := s.restartVaultEnv(info.ID, meta) + meta.Creds = creds spec := session.Spec{ID: info.ID, Dir: info.Dir, Created: info.Created, History: sess.History(), - Env: append(append(append(append(s.hostEnvFor(meta.Member), env...), l.env...), s.vaultEnvFor(info.ID, meta.Project, s.agentCreds(meta.Agent, meta.Project, meta.Account, meta.Creds), meta.Agent)...), run.environ()...), Command: l.command, Meta: meta} + Env: append(append(append(append(s.hostEnvFor(meta.Member), env...), l.env...), vaultEnv...), run.environ()...), Command: l.command, Meta: meta} started, err := s.sessions.Replace(spec) if err != nil { return proto.SessionInfo{}, err diff --git a/daemon/agentkey.go b/daemon/agentkey.go index f7a98f92..2ebf979a 100644 --- a/daemon/agentkey.go +++ b/daemon/agentkey.go @@ -70,7 +70,7 @@ func (s *Server) keyAwaitsApp(id string, meta session.Meta) bool { // names of its credentials afterwards: one it was given here shows only once lent. func (s *Server) restartVaultEnv(id string, meta session.Meta) (env, names []string) { creds := s.agentCreds(meta.Agent, meta.Project, meta.Account, meta.Creds) - env = s.vaultEnvFor(id, meta.Project, creds, meta.Agent) + env = s.vaultEnvFor(id, meta.Project, creds, meta.Agent, meta.Account) if len(creds) == len(meta.Creds) { return env, meta.Creds } diff --git a/daemon/agentkey_test.go b/daemon/agentkey_test.go index c2808333..7264b7e5 100644 --- a/daemon/agentkey_test.go +++ b/daemon/agentkey_test.go @@ -222,6 +222,40 @@ func TestPlatformKeyStaysOutOfTabs(t *testing.T) { takesKey(t, c, again.ID) } +// A tab that is this agent, under a session that isn't, asks for its own key as a new +// session does: the session's lease holds none, as the key is its agent's alone. +func TestPlatformTabUnderAnotherSessionTakesItsOwnKey(t *testing.T) { + fakePlatform(t) + srv := startServer(t) + c := connect(t, srv, nil) + app := serveVault(t, srv, keyLender(nil)) + putVault(t, app.Client, vault.Credential{Name: "anthropic", Platform: "anthropic", Fields: map[string]string{"api_key": platformTestKey}}) + parent, err := c.CreateSession(proto.SessionCreateParams{Agent: agent.Shell, Dir: t.TempDir(), Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + tab, err := c.CreateSession(proto.SessionCreateParams{Parent: parent.ID, Agent: agent.PlatformID, Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + takesKey(t, c, tab.ID) + if !slices.Contains(tab.Creds, "anthropic") { + t.Errorf("the tab keeps the key's name, which a restart lends again: %v", tab.Creds) + } + app.mu.Lock() + seen := slices.Clone(app.seen) + app.mu.Unlock() + i := slices.IndexFunc(seen, func(r proto.VaultRequest) bool { return r.Session == tab.ID }) + if i < 0 || seen[i].Agent != agent.PlatformID || seen[i].Action != proto.VaultSession { + t.Errorf("the app isn't told who asks, and with what: %+v", seen) + } + // Its session, another agent's, holds none of it. + var got proto.VaultAgentKey + if err := c.Call(proto.MethodVaultAgentKey, proto.VaultAgentKeyParams{Session: parent.ID}, &got); err != nil || len(got.Env) > 0 { + t.Errorf("its session took the tab's key: %v, %v", got.Env, err) + } +} + // With no credential in the vault the session still starts: the agent says what's missing. func TestPlatformStartsWithNoKey(t *testing.T) { starts := fakePlatform(t) diff --git a/daemon/create.go b/daemon/create.go index 4e7097fa..d84b9975 100644 --- a/daemon/create.go +++ b/daemon/create.go @@ -131,6 +131,9 @@ func (s *Server) createSessionFor(p proto.SessionCreateParams, member *proto.Ses } meta.Model, meta.Effort, meta.Mode = strings.TrimPrefix(p.Model, "default"), strings.TrimPrefix(p.Effort, "default"), p.Mode meta.ModelStarted = meta.Model + if p.Resume != "" && !p.Fork { + meta.Conversation = p.Resume // a fork's own gets an id of its own + } launch, err := launchFor(p, chat) if err != nil { s.configForget(id) @@ -231,7 +234,7 @@ func (s *Server) createSessionFor(p proto.SessionCreateParams, member *proto.Ses } if needsKey := s.agentKeyPlatform(p.Agent, p.Account) != ""; (p.NotesFor == "" && p.CanvasFor == "" || needsKey) && !p.SignIn && member == nil { // The vault's credentials it asked for, those its agent runs on, and its helpers. - env, names := s.vaultSessionEnv(spec.ID, meta.Project, meta.Parent, s.agentCreds(p.Agent, meta.Project, p.Account, p.Creds), p.Agent) + env, names := s.vaultSessionEnv(spec.ID, meta.Project, meta.Parent, s.agentCreds(p.Agent, meta.Project, p.Account, p.Creds), p.Agent, p.Account) spec.Env, spec.Meta.Creds = append(append(spec.Env, env...), chat.config.environ()...), names // its config's API over any key lent } // An agent that keeps no record of its tokens is metered through diff --git a/daemon/decide_uses.go b/daemon/decide_uses.go index 179da53d..2e24772b 100644 --- a/daemon/decide_uses.go +++ b/daemon/decide_uses.go @@ -10,10 +10,10 @@ import ( ) // The judge's later uses on this host (docs/decide.md): a model and effort -// for a prompt (route), the search results that bear on a prompt -// (context), a memory worth offering (memory), how a chat message -// notifies (chat), and whether a failed check is flaky (flaky). Each, off -// or unanswered, leaves things as they were without the judge. +// for a prompt (route), a memory worth offering (memory), how a chat +// message notifies (chat), and whether a failed check is flaky (flaky). +// Each, off or unanswered, leaves things as they were without the judge. +// A prompt's project search isn't judged: it runs on every prompt. // useOn says whether use is on on this host. func (s *Server) useOn(use string) bool { return decide.Use(s.judge.Settings().Get(), use).On } @@ -76,31 +76,6 @@ func (s *Server) routeFor(p proto.DecideRouteParams) (proto.RouteChoice, error) return out, nil } -// pickContext is those of hits (parts, as the prompt would get them) that -// bear on session info's prompt, as the judge says; all of them when the -// use is off or the judge has no sure answer. -func (s *Server) pickContext(info proto.SessionInfo, prompt string, hits []proto.RAGHit, parts []string) []proto.RAGHit { - if len(hits) == 0 || !s.useOn(proto.DecideContext) { - return hits - } - d, err := s.decideFor(info, proto.DecideAsk{Use: proto.DecideContext, Prompt: s.maskLent(prompt), Parts: parts}) - switch { - case err != nil: - return hits - case !s.sure(d): - if d.Judge != "" { - s.acted(d, proto.ActedNothing, 0) - } - return hits - case d.Verdict == proto.VerdictNone: - s.acted(d, proto.ActedDropped, 0) - return nil - } - kept := decide.Keep(hits, d) - s.acted(d, proto.ActedChosen, 0) - return kept -} - // memoryOf is what to offer as a memory of session info's agent's last // answer: the decision it holds, distilled; ok false when the judge says // it holds none. Off or unanswered, the answer as it is. diff --git a/daemon/decide_uses_test.go b/daemon/decide_uses_test.go index 8dc09cc7..506eaf19 100644 --- a/daemon/decide_uses_test.go +++ b/daemon/decide_uses_test.go @@ -107,7 +107,7 @@ func TestRouteChatAndFlakyThroughTheDaemon(t *testing.T) { r, err := c.DecideRoute(proto.DecideRouteParams{Agent: "claude", Prompt: "fix the typo in the README"}) if err != nil || r.Model != "claude-haiku-5-5" || r.Effort != "low" || r.Difficulty != proto.VerdictEasy || r.Why == "" || - strings.Join(r.Switch, "|") != "/model claude-haiku-5-5|/effort low" { + len(r.Switch) != 0 { t.Fatalf("easy: %+v %v", r, err) } r, err = c.DecideRoute(proto.DecideRouteParams{Agent: "codex", Prompt: "migrate the orders table to the new schema"}) @@ -193,9 +193,9 @@ func TestRouteChatAndFlakyThroughTheDaemon(t *testing.T) { } } -// RAG choices, through the daemon: a prompt gets only the parts the judge -// says bear on it, none when none do; a session's last answer is offered -// as a memory only when it holds a decision, distilled. +// A session's last answer is offered as a memory only when it holds a +// decision, distilled. A prompt's search is never judged: it runs on every +// prompt (TestEveryPromptGetsTheProjectsParts). func TestRAGChoicesThroughTheDaemon(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses sh") @@ -239,20 +239,13 @@ func TestRAGChoicesThroughTheDaemon(t *testing.T) { t.Fatal(err) } t.Setenv(session.EnvSession, inProject.ID()) + // A prompt's parts come from the search alone, the judge never asked. _, ctx, _ := hookOf(`{"prompt":"fix the invoice total tax"}`, "--event", proto.HookPromptStart) - if !strings.Contains(ctx, "billing/invoice.go") || strings.Contains(ctx, "ui/theme.go") { - t.Fatalf("the parts kept: %q", ctx) + if !strings.Contains(ctx, "billing/invoice.go") { + t.Fatalf("no parts: %q", ctx) } - _, ctx, _ = hookOf(`{"prompt":"make the colors of the total and tax screens warmer"}`, "--event", proto.HookPromptStart) - if strings.Contains(ctx, "enowX RAG") { - t.Fatalf("parts kept though none bear on it: %q", ctx) - } - acted := map[string]int{} - for _, d := range srv.judge.Log(proto.DecideLogParams{Use: proto.DecideContext}) { - acted[d.Acted]++ - } - if acted[proto.ActedChosen] != 1 || acted[proto.ActedDropped] != 1 { - t.Fatalf("context log %v", acted) + if acted := srv.judge.Log(proto.DecideLogParams{Use: proto.DecideContext}); len(acted) != 0 { + t.Fatalf("the judge chose the parts: %+v", acted) } // Two Claude Code sessions end: one settled on cents, one only reports. diff --git a/daemon/goa_switch.go b/daemon/goa_switch.go index 4f9f3eaa..0575dd44 100644 --- a/daemon/goa_switch.go +++ b/daemon/goa_switch.go @@ -285,6 +285,11 @@ func (s *Server) goaRunSwitch(sess *session.Session, plan goaSwitchPlan) (*sessi if !wasStopped { _, beforeConvo, _ = s.goaConversationFor(sess, proto.Account{}) } + // A key it would start without, no app being here to lend it: GOA keeps running as it + // was, rather than stop for an agent that says what is missing. + if s.keyAwaitsApp(id, plan.next) { + return nil, fmt.Errorf("goa: GOD of Agents keeps running as it was: %s runs on a key in the vault: %w", agentName(plan.next.Agent), errNoVaultApp) + } s.acctWatch.restarting(id) if err := s.goaStop(sess, plan.next); err != nil { s.acctWatch.settled(id, sess.Terminal().Text()) @@ -367,8 +372,11 @@ func (s *Server) goaStartOnAgent(id string, plan goaSwitchPlan) (*session.Sessio if err != nil { return nil, err } + // Its key, as a session starting again takes it: another agent's lease isn't its own. + vaultEnv, creds := s.restartVaultEnv(id, meta) + meta.Creds = creds spec := session.Spec{ID: id, Dir: info.Dir, Created: info.Created, History: sess.History(), Command: l.command, Meta: meta, - Env: append(append(append(append(append(s.hostEnvFor(nil), env...), l.env...), s.vaultEnvFor(id, "", meta.Creds, meta.Agent)...), run.environ()...), s.tokenTrafficEnv(id, p.Agent, "", meta.Name)...)} + Env: append(append(append(append(append(s.hostEnvFor(nil), env...), l.env...), vaultEnv...), run.environ()...), s.tokenTrafficEnv(id, p.Agent, "", meta.Name)...)} next, err := s.sessions.Replace(spec) if err != nil { return nil, err diff --git a/daemon/goa_tasks.go b/daemon/goa_tasks.go index b89ab26a..61dd8208 100644 --- a/daemon/goa_tasks.go +++ b/daemon/goa_tasks.go @@ -8,7 +8,6 @@ import ( "time" "github.com/enowx/enowx/core/agent" - "github.com/enowx/enowx/core/decide" "github.com/enowx/enowx/core/goa" "github.com/enowx/enowx/core/noteblocks" "github.com/enowx/enowx/proto" @@ -58,11 +57,11 @@ func (s *Server) goaDelegate(p proto.GOADelegateParams) (proto.GOATask, error) { if t.Agent == "" { t.Agent = "claude" } - var switches []string + asked := proto.SessionSettingsParams{ID: t.Session} switch { case p.Model != "" || p.Effort != "" || p.Config != "": t.Override, t.Why = true, "chosen by hand" - switches = decide.Route{Model: p.Model, Effort: p.Effort}.Switch(t.Agent) + asked.Model, asked.Effort = p.Model, p.Effort case p.NoRoute: t.Why = "the agent's own model and effort, as asked" default: @@ -80,7 +79,7 @@ func (s *Server) goaDelegate(p proto.GOADelegateParams) (proto.GOATask, error) { // Routing picks what a task starts on: a session already there keeps its own. t.Difficulty, t.Why = rc.Difficulty, rc.Why+" (the session keeps its own model and effort)" default: - t.Model, t.Effort, t.Difficulty, t.Why, switches = rc.Model, rc.Effort, rc.Difficulty, rc.Why, rc.Switch + t.Model, t.Effort, t.Difficulty, t.Why = rc.Model, rc.Effort, rc.Difficulty, rc.Why if rc.Config != "" && t.Config == "" { t.Config = rc.Config } @@ -103,11 +102,12 @@ func (s *Server) goaDelegate(p proto.GOADelegateParams) (proto.GOATask, error) { if t.Status == proto.GOATaskQueued { return s.goaAddTask(p, t, target) } - for _, line := range switches { - if err := s.goaCall(p.From, p.Host, proto.MethodSessionInput, proto.SessionInputParams{ID: t.Session, Text: line, Enter: true}, nil); err != nil { - return t, err + if asked.Model != "" || asked.Effort != "" { + // The task goes either way, the board saying what it runs on. + if err := s.goaCall(p.From, p.Host, proto.MethodSessionSettings, asked, nil); err != nil { + t.Model, t.Effort = "", "" + t.Why = strings.TrimSpace(t.Why + " Its session keeps its own model and effort: " + err.Error()) } - time.Sleep(switchPause) } if err := s.goaCall(p.From, p.Host, proto.MethodSessionInput, proto.SessionInputParams{ID: t.Session, Text: p.Prompt, Paste: true, Enter: true}, nil); err != nil { return t, err @@ -649,16 +649,13 @@ func (s *Server) goaOverride(p proto.GOAOverrideParams) (proto.GOATask, error) { if t.ID == "" { return t, fmt.Errorf("goa: no task %q on the board", p.ID) } - lines := decide.Route{Model: p.Model, Effort: p.Effort}.Switch(t.Agent) - if len(lines) == 0 { + set := agent.SettingsOf(t.Agent) + if set.ModelCommand == "" && set.EffortCommand == "" { return t, fmt.Errorf("goa: %s's model and effort can't be switched while it runs", t.Agent) } if !goa.Settled(t.Status) && t.Session != "" { - for _, line := range lines { - if err := s.goaCall("", t.Host, proto.MethodSessionInput, proto.SessionInputParams{ID: t.Session, Text: line, Enter: true}, nil); err != nil { - return t, err - } - time.Sleep(switchPause) + if err := s.goaCall("", t.Host, proto.MethodSessionSettings, proto.SessionSettingsParams{ID: t.Session, Model: p.Model, Effort: p.Effort}, nil); err != nil { + return t, err } } t, err := s.goa.board.Update(t.ID, func(t *proto.GOATask) { diff --git a/daemon/hibernate_test.go b/daemon/hibernate_test.go index 09171180..81847257 100644 --- a/daemon/hibernate_test.go +++ b/daemon/hibernate_test.go @@ -157,7 +157,17 @@ func TestHibernateLeavesATaskUnderWay(t *testing.T) { if err := srv.sessions.SetState(sess, proto.StateDone); err != nil { t.Fatal(err) } - time.Sleep(20 * time.Millisecond) + // Its turn's end moves the board too, and settles that task. Put it back as one + // GOA has given and the session hasn't taken yet: that is the shape hibernation + // must keep off, and the plan is checked so it does so for that reason and not + // because the session still reads as active. + waitFor(t, "the turn's end on the board", func() bool { return taskStatus(t, srv, task.ID).Status == proto.GOATaskTurnEnded }) + if _, err := srv.goa.board.Update(task.ID, func(x *proto.GOATask) { x.Status = proto.GOATaskWorking }); err != nil { + t.Fatal(err) + } + if plan := srv.hibernatePlan(sess, nil); plan.Task != task.ID { + t.Fatalf("its task under way: %+v", plan) + } srv.hibernate() if list, _ := c.Sessions(); list[0].Hibernated { t.Fatal("hibernated on its own, its task under way") diff --git a/daemon/hook.go b/daemon/hook.go index 53959f7e..28d8a996 100644 --- a/daemon/hook.go +++ b/daemon/hook.go @@ -143,12 +143,13 @@ func agentEvent(event string, data []byte) proto.HookEvent { Scratch string `json:"scratchpad_dir"` Last string `json:"last_assistant_message"` Source string `json:"source"` + Convo string `json:"session_id"` Tasks json.RawMessage `json:"background_tasks"` } _ = json.Unmarshal(data, &in) ev := hooks.ToolEvent(event, in.Tool, in.Input, in.Cwd) ev.Prompt, ev.PermissionMode, ev.Scratchpad, ev.LastMessage, ev.Failed = in.Prompt, in.Mode, in.Scratch, in.Last, in.Event == "PostToolUseFailure" || in.Error != "" - ev.Source, ev.BackgroundTasks = in.Source, countOf(in.Tasks) + ev.Source, ev.Conversation, ev.BackgroundTasks = in.Source, in.Convo, countOf(in.Tasks) return ev } diff --git a/daemon/import.go b/daemon/import.go index 6ccec071..c075e22b 100644 --- a/daemon/import.go +++ b/daemon/import.go @@ -185,6 +185,7 @@ func (s *Server) importSession(in *importing, ms transfer.Session, ids map[strin meta := session.Meta{ Name: ms.Name, Prompt: ms.Prompt, Project: in.dest, Agent: ms.Agent, Branch: ms.Branch, Worktree: ms.Worktree, SkipPermissions: ms.Skip, Budget: time.Duration(ms.Budget) * time.Second, Parent: ids[ms.Parent], + Conversation: convo, } spec := session.Spec{ID: session.NewID(), Dir: dir, Created: ms.Created, History: in.history[ms.ID], Env: s.sessionEnv()} var command []string diff --git a/daemon/platform_e2e_session_test.go b/daemon/platform_e2e_session_test.go index ece87699..c6a9f69c 100644 --- a/daemon/platform_e2e_session_test.go +++ b/daemon/platform_e2e_session_test.go @@ -7,30 +7,24 @@ import ( "os" "path/filepath" "slices" - "strconv" "strings" "syscall" "testing" + "github.com/enowx/enowx/core/procstat" "github.com/enowx/enowx/internal/standinapi" "github.com/enowx/enowx/proto" ) // agentProcess is the PID of session id's agent program itself: the wrappers' shells -// hold the same words on their command lines, but aren't the program. +// hold the same words on their command lines, but aren't the program. Its first word +// tells it from the shell wrapping it. Read through procstat, as macOS has no /proc. func (e *e2e) agentProcess(id string) int { - entries, _ := os.ReadDir("/proc") - for _, entry := range entries { - pid, err := strconv.Atoi(entry.Name()) - if err != nil { - continue - } - // By its command line: the agent keeps its /proc entries from its own user, exe - // and environ included. Its first word tells it from the shell wrapping it. - data, _ := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) - line := strings.ReplaceAll(string(data), "\x00", " ") + procs, _ := procstat.Snapshot() + for _, p := range procs { + line := strings.Join(procstat.Args(p.PID), " ") if strings.HasPrefix(line, e2eBin+" ") && strings.Contains(line, "claude-platform") && strings.Contains(line, id) { - return pid + return p.PID } } return 0 @@ -42,8 +36,7 @@ func (e *e2e) agentCommandLine(id string) string { if pid == 0 { return "" } - data, _ := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) - return strings.ReplaceAll(string(data), "\x00", " ") + return strings.Join(procstat.Args(pid), " ") } // A hibernated session wakes on its conversation, cost and rules; so does one whose diff --git a/daemon/platform_e2e_test.go b/daemon/platform_e2e_test.go index dcd5e025..1e4e543b 100644 --- a/daemon/platform_e2e_test.go +++ b/daemon/platform_e2e_test.go @@ -18,6 +18,7 @@ import ( "github.com/enowx/enowx/client" "github.com/enowx/enowx/core/agent" "github.com/enowx/enowx/core/decide" + "github.com/enowx/enowx/core/procstat" "github.com/enowx/enowx/internal/standinapi" "github.com/enowx/enowx/proto" "github.com/enowx/enowx/transport" @@ -148,13 +149,14 @@ func (e *e2e) assertNoKeyOnDisk() { }) } -// assertNoKeyInCommandLines is what `ps` shows of every process, agents' included. +// assertNoKeyInCommandLines is what `ps` shows of every process, agents' included. Read +// through procstat, as macOS has no /proc. func (e *e2e) assertNoKeyInCommandLines() { e.t.Helper() - entries, _ := os.ReadDir("/proc") - for _, entry := range entries { - if data, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")); err == nil && strings.Contains(string(data), e2eKey) { - e.t.Errorf("the key is on the command line of process %s", entry.Name()) + procs, _ := procstat.Snapshot() + for _, p := range procs { + if strings.Contains(strings.Join(procstat.Args(p.PID), " "), e2eKey) { + e.t.Errorf("the key is on the command line of process %d", p.PID) } } } diff --git a/daemon/platform_e2e_turn_test.go b/daemon/platform_e2e_turn_test.go index aea512a9..0dbf5826 100644 --- a/daemon/platform_e2e_turn_test.go +++ b/daemon/platform_e2e_turn_test.go @@ -3,13 +3,12 @@ package daemon import ( - "fmt" - "os" "path/filepath" "slices" "strings" "testing" + "github.com/enowx/enowx/core/procstat" "github.com/enowx/enowx/internal/standinapi" "github.com/enowx/enowx/proto" ) @@ -229,18 +228,10 @@ func e2eQuestions(t *testing.T) { // processWith is a running process whose whole command line is exactly command, 0 for // none: a program's arguments merely naming it don't count. func processWith(command string) int { - entries, _ := os.ReadDir("/proc") - for _, entry := range entries { - var pid int - if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil { - continue - } - data, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) - if err != nil { - continue - } - if strings.TrimRight(strings.ReplaceAll(string(data), "\x00", " "), " ") == command { - return pid + procs, _ := procstat.Snapshot() + for _, p := range procs { + if strings.Join(procstat.Args(p.PID), " ") == command { + return p.PID } } return 0 diff --git a/daemon/rag.go b/daemon/rag.go index 06f853a6..b3775bb5 100644 --- a/daemon/rag.go +++ b/daemon/rag.go @@ -28,12 +28,24 @@ const ragQuiet = 15 * time.Second // ragContextMax bounds the context a prompt gets from the index. const ragContextMax = 6000 +// ragContextHits is how many parts a prompt gets: the four most like it. +const ragContextHits = 4 + +// ragWordsTimeout bounds the word search a failed embedder falls back to: +// it is local, so it costs the prompt next to nothing. +const ragWordsTimeout = 2 * time.Second + +// ragContextTimeout bounds a prompt's search: past it the prompt goes on +// without the index rather than wait (a search measured at p90 300 ms). +const ragContextTimeout = 3 * time.Second + // ragState is what the daemon keeps of indexes. mu guards it. type ragState struct { mu sync.Mutex status map[string]proto.RAGStatus // by project folder running map[string]bool soon map[string]*time.Timer + failed map[string]string // by project folder: why its last prompt search failed } // newRAG makes the host's RAG, keys asked of the vault. @@ -55,7 +67,8 @@ func (s *Server) newRAG(dir string) { } return "", fmt.Errorf("rag: the credential %s has no value", cred) } - s.ragRun = ragState{status: map[string]proto.RAGStatus{}, running: map[string]bool{}, soon: map[string]*time.Timer{}} + s.rag.OnEmbed = func(project string, tokens int64) { s.ragTokens(project, tokens) } + s.ragRun = ragState{status: map[string]proto.RAGStatus{}, running: map[string]bool{}, soon: map[string]*time.Timer{}, failed: map[string]string{}} } // ragSetupDir is ragDir for looking at and indexing a project, not writing to @@ -99,11 +112,12 @@ func (s *Server) ragStatus(dir string) proto.RAGStatus { st := s.ragSettings.Get() s.ragRun.mu.Lock() out, ok := s.ragRun.status[dir] - running := s.ragRun.running[dir] + running, failed := s.ragRun.running[dir], s.ragRun.failed[dir] s.ragRun.mu.Unlock() if !ok { out, _ = s.rag.Status(context.Background(), dir) } + out.ContextError = failed out.Project, out.ID, out.Settings, out.Indexing = dir, id, st.Projects[id], running out.Ready = st.Embedder == proto.RAGVoyage || st.Embedder == proto.RAGOpenAI || st.Embedder == proto.RAGTEI out.Embedder = s.rag.ProjectHost(dir) @@ -147,7 +161,6 @@ func (s *Server) ragIndex(dir string) (proto.RAGStatus, error) { go func() { s.broadcast(proto.EventRAGProgress, s.ragStatus(dir)) res, err := s.rag.Index(context.Background(), dir, p, s.ragExtra(dir, id)) - s.ragTokens(dir, res.Tokens) res.Indexed, res.Phase = time.Now(), "done" if err != nil { res.Error = err.Error() @@ -210,25 +223,42 @@ func (s *Server) ragSoon(dir string) { }) } -// ragContext is what a prompt in project dir gets from its index, when -// the project asks for it: the parts most like the prompt, those that bear -// on it when the judge is asked (session info's, use context). -func (s *Server) ragContext(info proto.SessionInfo, dir, prompt string) string { +// ragContext is what a prompt in project dir gets from its index: every +// prompt of a session whose project is indexed gets the four parts most +// like it, up to ragContextMax characters. Nothing skips it but consent: +// a project not turned on, or not agreed to for this embedder, sends +// nothing. When the embedder fails the search falls back to the index's +// words, its parts marked so, and the failure is kept for the RAG settings. +func (s *Server) ragContext(dir, prompt string) string { + if strings.TrimSpace(prompt) == "" { + return "" + } p := s.ragSettings.Project(rag.ProjectID(dir)) - if !p.On || !p.Context || strings.TrimSpace(prompt) == "" { + if !p.On || p.Consent != s.rag.ProjectHost(dir) { return "" } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), ragContextTimeout) defer cancel() - hits, err := s.rag.Search(ctx, dir, prompt, 4, nil) - if err != nil || len(hits) == 0 { + st, err := s.rag.Status(ctx, dir) + if err != nil { + s.ragContextFailed(dir, err) // no embedder, or its key: nothing searched return "" } - parts := make([]string, len(hits)) - for i, h := range hits { - parts[i] = hitPart(h) + if st.Chunks == 0 { + return "" // no index: nothing to find, and nothing to send + } + hits, err := s.rag.Search(ctx, dir, prompt, ragContextHits, nil) + if err != nil { + s.ragContextFailed(dir, err) + // The index's words alone, in this host's own store: no network, so + // the prompt waits no longer than this. + wctx, wcancel := context.WithTimeout(context.Background(), ragWordsTimeout) + defer wcancel() + hits, _ = s.rag.Words(wctx, dir, prompt, ragContextHits, nil) + } else { + s.ragContextFailed(dir, nil) } - if hits = s.pickContext(info, prompt, hits, parts); len(hits) == 0 { + if len(hits) == 0 { return "" } var b strings.Builder @@ -243,6 +273,21 @@ func (s *Server) ragContext(info proto.SessionInfo, dir, prompt string) string { return b.String() } +// ragContextFailed keeps err as the reason project dir's last prompt search +// failed (nil: it worked), for the RAG settings and the log. +func (s *Server) ragContextFailed(dir string, err error) { + s.ragRun.mu.Lock() + if err == nil { + delete(s.ragRun.failed, dir) + } else { + s.ragRun.failed[dir] = err.Error() + } + s.ragRun.mu.Unlock() + if err != nil { + s.logger().Warn("rag: a prompt's search failed", "project", dir, "err", err) + } +} + // hitPart is a search result as a prompt's context shows it: where, then // its text. func hitPart(h proto.RAGHit) string { @@ -256,6 +301,9 @@ func hitPart(h proto.RAGHit) string { if where == "" { where = h.Source } + if h.Words { + where += " (found by words: the embedder failed)" + } return "\n" + where + "\n" + h.Snippet + "\n" } diff --git a/daemon/rag_context_test.go b/daemon/rag_context_test.go new file mode 100644 index 00000000..7afa257f --- /dev/null +++ b/daemon/rag_context_test.go @@ -0,0 +1,157 @@ +package daemon + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/core/tokens" + "github.com/enowx/enowx/proto" +) + +// ragProject is a project with one file to find, indexed and agreed to. +func ragProject(t *testing.T, srv *Server, embedder string, p proto.RAGProject) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "billing"), 0o755) + os.WriteFile(filepath.Join(dir, "billing", "invoice.go"), + []byte("package billing\n\n// Total sums an invoice's lines with tax.\nfunc Total(lines []float64, tax float64) float64 {\n\treturn 0\n}\n"), 0o644) + os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Shop\n\nOrders and refunds.\n"), 0o644) + if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + t.Skipf("git: %s", out) + } + if err := srv.ragSettings.Set(proto.RAGSettings{Embedder: proto.RAGOpenAI, Model: "fake", BaseURL: embedder}); err != nil { + t.Fatal(err) + } + st, _ := srv.setRAGProject(proto.RAGProjectParams{Project: dir, Settings: p}) + p.Consent = st.Embedder + srv.setRAGProject(proto.RAGProjectParams{Project: dir, Settings: p}) + if _, err := srv.ragIndex(dir); err != nil { + t.Fatal(err) + } + waitFor(t, "the index", func() bool { s := srv.ragStatus(dir); return !s.Indexing && s.Chunks >= 2 }) + return dir +} + +// ragPromptSession is a session in dir, its hooks running as its agent's. +func ragPromptSession(t *testing.T, srv *Server, dir string) { + t.Helper() + s, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "cat"}, Dir: dir, Cols: 80, Rows: 24, Meta: session.Meta{Project: dir}}) + if err != nil { + t.Fatal(err) + } + t.Setenv(session.EnvSession, s.ID()) +} + +// ragOwn are the calls counted as RAG's own. +func ragOwn(srv *Server) []tokens.Call { + var out []tokens.Call + for _, c := range srv.tok.store.Calls(time.Now().Add(-time.Hour), time.Now().Add(time.Minute)) { + if c.Own == proto.TokensOwnRAG { + out = append(out, c) + } + } + return out +} + +// Every prompt of a session whose project is indexed gets the parts that +// bear on it, whatever the project's switch says and whatever the judge +// would: the switch is no longer read, and the judge isn't asked. +func TestEveryPromptGetsTheProjectsParts(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + srv, _ := hookServer(t) + t.Setenv("HOME", t.TempDir()) + judgeLater(t, srv) // the judge would drop what doesn't bear on the prompt + dir := ragProject(t, srv, ragEmbeddings(t), proto.RAGProject{On: true, Context: false}) + ragPromptSession(t, srv, dir) + + _, ctx, _ := hookOf(`{"prompt":"make the colors of the total and tax screens warmer"}`, "--event", proto.HookPromptStart) + if !strings.Contains(ctx, "billing/invoice.go") { + t.Fatalf("the switch off skipped the search: %q", ctx) + } + if acted := srv.judge.Log(proto.DecideLogParams{Use: proto.DecideContext}); len(acted) != 0 { + t.Fatalf("the judge was asked which parts to keep: %+v", acted) + } +} + +// A prompt's search embeds its query: it is counted as RAG's own use, one +// call a prompt, beside the index's. +func TestPromptSearchIsCounted(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + srv, _ := hookServer(t) + if _, err := srv.tok.settings.Set(proto.TokensSettings{On: true}); err != nil { + t.Fatal(err) + } + dir := ragProject(t, srv, ragEmbeddings(t), proto.RAGProject{On: true}) + ragPromptSession(t, srv, dir) + indexed := len(ragOwn(srv)) + if indexed == 0 { + t.Fatal("the index's embeddings weren't counted") + } + hookOf(`{"prompt":"fix the invoice total tax"}`, "--event", proto.HookPromptStart) + if got := len(ragOwn(srv)); got != indexed+1 { + t.Fatalf("%d calls after a prompt, %d after the index: the query wasn't counted", got, indexed) + } + hookOf(`{"prompt":"what does the refund flow do"}`, "--event", proto.HookPromptStart) + if got := len(ragOwn(srv)); got != indexed+2 { + t.Fatalf("%d calls after two prompts: %d", got, indexed) + } +} + +// An embedder that fails doesn't hold the prompt up: it goes on within the +// search's timeout, with what the index's words find, marked as such, and +// the failure shows in the RAG settings. +func TestPromptSearchFallsBackWhenTheEmbedderFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + srv, _ := hookServer(t) + var broken atomic.Bool + inner := ragEmbeddings(t) + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if broken.Load() { + http.Error(w, "the embedder is down", http.StatusInternalServerError) + return + } + resp, err := http.Post(inner+"/embeddings", "application/json", r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + var out json.RawMessage + json.NewDecoder(resp.Body).Decode(&out) + w.Header().Set("Content-Type", "application/json") + w.Write(out) + })) + defer srv2.Close() + dir := ragProject(t, srv, srv2.URL+"/v1", proto.RAGProject{On: true}) + ragPromptSession(t, srv, dir) + + broken.Store(true) + start := time.Now() + _, ctx, _ := hookOf(`{"prompt":"fix the invoice total tax"}`, "--event", proto.HookPromptStart) + took := time.Since(start) + if took > ragContextTimeout+5*time.Second { + t.Fatalf("the prompt waited %s on a failing embedder", took) + } + if !strings.Contains(ctx, "billing/invoice.go") || !strings.Contains(ctx, "found by words") { + t.Fatalf("no word fallback: %q", ctx) + } + if st := srv.ragStatus(dir); !strings.Contains(st.ContextError, "500") { + t.Fatalf("the failure isn't in the status: %+v", st) + } +} diff --git a/daemon/rpc.go b/daemon/rpc.go index dae137a5..2309b27e 100644 --- a/daemon/rpc.go +++ b/daemon/rpc.go @@ -73,7 +73,7 @@ var slowMethods = slow(methodsOf(reviewHandlers, tokenHandlers, cloudHandlers, l proto.MethodSessionRewind, proto.MethodAgentHistory, proto.MethodAutomationRun, proto.MethodSessionFanOut, proto.MethodBrowserAct, proto.MethodFeaturesRequest, proto.MethodNoteChat, proto.MethodNoteChatLog, proto.MethodCanvasRender, proto.MethodProjectCopyRequest, proto.MethodProjectSyncRequest, proto.MethodAutopushNow, proto.MethodAutopushSet, proto.MethodProjectKeepCopy, proto.MethodProjectFileRead, - proto.MethodSessionCreate, proto.MethodSessionContinue, proto.MethodSessionFork, proto.MethodSessionMode, + proto.MethodSessionCreate, proto.MethodSessionContinue, proto.MethodSessionFork, proto.MethodSessionMode, proto.MethodSessionSettings, proto.MethodVaultAsk, proto.MethodVaultAttach, proto.MethodVaultScan, proto.MethodHostKeysToVault, // An unslop asks the app it came from to read its page, and makes sessions. proto.MethodBrowserUnslop, proto.MethodBrowserUnslopShots, proto.MethodCanvasUnslop, proto.MethodCanvasBuild, proto.MethodCanvasMatch) diff --git a/daemon/rpc_hooks.go b/daemon/rpc_hooks.go index bf172e81..ed281dfd 100644 --- a/daemon/rpc_hooks.go +++ b/daemon/rpc_hooks.go @@ -70,6 +70,9 @@ func (s *Server) sessionEvent(p proto.SessionHookParams) (proto.HookDecision, er if err != nil { return proto.HookDecision{}, err } + if p.Event.Event == proto.HookSessionStart { + s.conversationStarted(sess, p.Event) + } // Answered before it counts as waiting: nothing is asked of the user. if p.Event.Event == proto.HookPermission && s.bypassAnswer(sess.Info(), p.Event) { if p.Event.PermissionMode != "" { @@ -141,7 +144,7 @@ func (s *Server) sessionEvent(p proto.SessionHookParams) (proto.HookDecision, er s.todoHead(info) if !d.Block { go s.turnStarted(info) - if extra := s.ragContext(info, dir, ev.Prompt); extra != "" { + if extra := s.ragContext(dir, ev.Prompt); extra != "" { d.Context = strings.TrimSpace(d.Context + "\n\n" + extra) } } diff --git a/daemon/rpc_session.go b/daemon/rpc_session.go index 4f077ce1..a26846e0 100644 --- a/daemon/rpc_session.go +++ b/daemon/rpc_session.go @@ -52,6 +52,9 @@ var sessionHandlers = map[string]handler{ proto.MethodSessionMode: with(func(c *conn, p proto.SessionModeParams) (any, error) { return ok, c.srv.switchMode(p) }), + proto.MethodSessionSettings: with(func(c *conn, p proto.SessionSettingsParams) (any, error) { + return ok, c.srv.switchSettings(p) + }), proto.MethodAgentHistory: with(func(c *conn, p proto.HistoryParams) (any, error) { return c.srv.history(p) }), diff --git a/daemon/server.go b/daemon/server.go index c2eb775b..2d0f513d 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -48,6 +48,7 @@ type Server struct { control controlLog // requests made through host.call (hostcontrol.go) agentModes agentModes // what agents' hooks last said of their permission mode (agentmode.go) modeSwitches sync.Map // session ID → *sync.Mutex: one mode switch at a time, as two pressing the key at once could end on a mode neither asked for (switchMode) + modelFloors sync.Map // session ID → modelFloor: where its transcript stood as its model was switched (session_settings.go) // WorktreeRoot is where worktrees are created. WorktreeRoot string // Hash is the SHA-256 of the executable the daemon started from diff --git a/daemon/session_model.go b/daemon/session_model.go index a9402ee9..1ac925d0 100644 --- a/daemon/session_model.go +++ b/daemon/session_model.go @@ -97,21 +97,11 @@ func (s *Server) readModel(home string, info proto.SessionInfo, f *modelFollow) if own := s.transcripts.owned(info.ID); own != "" && own != f.path { f.path, f.stamp, f.length = own, time.Time{}, 0 } - // Files are found by folder alone, so a newer one may be another session's, - // as a fork's: only a session at work can have started a new conversation. + // Only a session at work can have started a new conversation. if f.path == "" && info.State != proto.StateDone || info.State == proto.StateWorking && time.Since(f.found) > modelFind { f.found = time.Now() - for _, found := range transcript.FindIn(info.Agent, home, info.Dir, s.agentStore(info.Agent, info.ID, false)) { - if found.Changed.Before(info.Created.Add(-time.Second)) { - break - } - if found.Path == f.path { - break - } - if s.transcripts.claim(info.ID, found.Path, f.path) { - f.path, f.stamp, f.length = found.Path, time.Time{}, 0 - break - } + if path := s.ownTranscript(info.ID, info, home, f.path); path != "" && path != f.path { + f.path, f.stamp, f.length = path, time.Time{}, 0 } } if f.path == "" { @@ -122,7 +112,7 @@ func (s *Server) readModel(home string, info proto.SessionInfo, f *modelFollow) return said, false } f.stamp, f.length = st.ModTime(), st.Size() - said = transcript.LastSettings(info.Agent, f.path) + said = transcript.LastSettingsSince(info.Agent, f.path, s.floorOf(info.ID, f.path)) return said, said.Model != "" } diff --git a/daemon/session_settings.go b/daemon/session_settings.go new file mode 100644 index 00000000..7fdbb0cd --- /dev/null +++ b/daemon/session_settings.go @@ -0,0 +1,142 @@ +package daemon + +import ( + "fmt" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/decide" + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// Changing one session's model or effort must not change the user's other sessions. Claude +// Code's /model and /effort save what they set in the settings file all its sessions read +// (2.1.318: "saved as your default for new sessions"), so they are never typed for an agent +// that does that (Settings.SavesDefaults): it starts again on its conversation with its flags, +// which hold for that process alone. A restart needs a moment when nothing is lost, and says so. + +// modelFloor is where session's transcript stood as its model was switched: what it says +// before that is of the model it left. +type modelFloor struct { + path string + size int64 +} + +// floorOf is how much of the transcript at path to pass over for session id. +func (s *Server) floorOf(id, path string) int64 { + if v, ok := s.modelFloors.Load(id); ok { + if f := v.(modelFloor); f.path == path { + return f.size + } + } + return 0 +} + +// keepModelFloor notes where session id's transcript stands, its agent stopped. +func (s *Server) keepModelFloor(id string) { + path := s.transcriptPath(id) + if st, err := os.Stat(path); path != "" && err == nil { + s.modelFloors.Store(id, modelFloor{path: path, size: st.Size()}) + } +} + +// switchSettings puts session p.ID's agent on p.Model and p.Effort, or says why not. +func (s *Server) switchSettings(p proto.SessionSettingsParams) error { + if p.Model == "" && p.Effort == "" { + return fmt.Errorf("session %s: a switch to which model or effort?", p.ID) + } + sess, err := s.sessions.Get(p.ID) + if err != nil { + return err + } + info := sess.Info() + set := agent.SettingsOf(info.Agent) + if p.Model != "" && set.ModelCommand == "" || p.Effort != "" && set.EffortCommand == "" { + return fmt.Errorf("%s's model and effort can't be changed while it runs", agentName(info.Agent)) + } + if !set.SavesDefaults { + return s.typeSettings(p, info.Agent) + } + if strings.ContainsAny(p.Model, " <\n") { + return fmt.Errorf("%q isn't a model's name", p.Model) + } + if p.Effort != "" && p.Effort != "default" && !slices.ContainsFunc(set.Efforts, func(c agent.Choice) bool { return c.ID == p.Effort }) { + return fmt.Errorf("%s has no effort %q", agentName(info.Agent), p.Effort) + } + v, _ := s.modeSwitches.LoadOrStore(p.ID, new(sync.Mutex)) + turn := v.(*sync.Mutex) + turn.Lock() + defer turn.Unlock() + info = sess.Info() + meta := sess.Meta() + refused := func(why string) error { + return fmt.Errorf("session %s stays as it is: %s would save the change for all its sessions, so it changes by starting again on its conversation, and %s", p.ID, agentName(info.Agent), why) + } + switch { + case meta.Config != nil: + return fmt.Errorf("session %s runs on the config %s, which decides its model and effort: switch its config", p.ID, meta.Config.Name) + case info.Queued: + return refused("it hasn't started yet") + case !comesBack(meta): + return refused("it can't come back") + } + withSettings(&meta, p) + if info.Exited { + s.keepModelFloor(info.ID) + s.sessions.SetModel(sess, meta.Model, meta.Effort, meta.ModelStarted, meta.ModelMoved) + return nil // it comes back on them + } + if atWork(info) { + return refused("its agent is at work or asking something: try again at its turn's end") + } + ports, _ := s.allPorts() + if s.stoppingLoses("", info.ID, ports) { + return refused("that would stop what it runs or serves") + } + s.procsLook() + stopped := meta + stopped.Hibernated = true // for a moment: to GOA's board it hasn't ended, its task hasn't stopped + if _, err := s.sessions.Replace(stoppedSpec(sess, stopped)); err != nil { + return err + } + s.wakes.undo(info.ID) + s.reapSession(info.ID) + s.keepModelFloor(info.ID) + if _, err := s.wakeSessionIn(info.ID, ""); err != nil { + return err + } + return s.waitSessionReady(info.ID) +} + +// withSettings has meta say p's model and effort: given by the user, so no move. +func withSettings(meta *session.Meta, p proto.SessionSettingsParams) { + if p.Model != "" { + meta.Model = strings.TrimPrefix(p.Model, "default") // no flag: the agent's own + meta.ModelStarted, meta.ModelMoved = meta.Model, false + } + if p.Effort != "" { + meta.Effort = strings.TrimPrefix(p.Effort, "default") + } +} + +// typeSettings tells an agent whose commands hold for its session alone (Claude Platform), as +// its user would, bringing it back first if it stopped. +func (s *Server) typeSettings(p proto.SessionSettingsParams, id string) error { + _, err := s.wakeThen(p.ID, func() error { + for i, line := range (decide.Route{Model: p.Model, Effort: p.Effort}).Switch(id) { + if i > 0 { + time.Sleep(switchPause) + } + if err := s.input(proto.SessionInputParams{ID: p.ID, Text: line, Enter: true}); err != nil { + return err + } + } + return nil + }) + return err +} diff --git a/daemon/session_settings_test.go b/daemon/session_settings_test.go new file mode 100644 index 00000000..fab6e0ca --- /dev/null +++ b/daemon/session_settings_test.go @@ -0,0 +1,135 @@ +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/enowx/enowx/proto" +) + +// The owner's bug: a switch of one session typed /model, and Claude Code saved it in +// ~/.claude/settings.json for every session. The fake does what the real one does with what is +// typed, in a real file: switching by session.settings, a task's model and the board's Change +// model leave it as it was, and each lands on the agent's command line. +func TestSwitchLeavesTheUsersDefaultAlone(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses bash") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + settings := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(settings), 0o700); err != nil { + t.Fatal(err) + } + const mine = `{"model":"opus"}` + if err := os.WriteFile(settings, []byte(mine), 0o600); err != nil { + t.Fatal(err) + } + starts := filepath.Join(home, "starts") + bin := t.TempDir() + // Bash reads and writes by itself: a program it ran would count as what stopping it loses. + script := fmt.Sprintf(`#!/usr/bin/env bash +printf '%%s\n' "$*" >> %q +echo started $$ +while IFS= read -r line; do + case "$line" in + "/model "*) printf '{"model":"%%s"}' "${line#/model }" > %q ;; + "/effort "*) printf '{"effortLevel":"%%s"}' "${line#/effort }" > %q ;; + esac +done +`, starts, settings, settings) + if err := os.WriteFile(filepath.Join(bin, "claude"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + srv := startServer(t) + c := connect(t, srv, nil) + s, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", Dir: t.TempDir(), Cols: 100, Rows: 30}) + if err != nil { + t.Fatal(err) + } + waitFor(t, "its agent started", func() bool { return len(linesOf(starts)) == 1 }) + // Its hooks would say so: at rest between turns. + rest := func() { + t.Helper() + if err := c.Call(proto.MethodSessionState, proto.SessionStateParams{ID: s.ID, State: proto.StateDone}, nil); err != nil { + t.Fatal(err) + } + } + kept := func(what string, want ...string) { + t.Helper() + if got, _ := os.ReadFile(settings); string(got) != mine { + t.Fatalf("%s changed the user's settings: %s", what, got) + } + all := linesOf(starts) + for _, w := range want { + if !strings.Contains(all[len(all)-1], w) { + t.Fatalf("%s: started without %s: %q", what, w, all) + } + } + } + + rest() + if err := c.SwitchSettings(proto.SessionSettingsParams{ID: s.ID, Model: "claude-sonnet-5-5", Effort: "low"}); err != nil { + t.Fatal(err) + } + kept("a switch", "--model claude-sonnet-5-5", "--effort low") + sess, err := srv.sessions.Get(s.ID) + if err != nil { + t.Fatal(err) + } + if info := sess.Info(); info.Model != "claude-sonnet-5-5" || info.Effort != "low" { + t.Fatalf("the session says %q, %q", info.Model, info.Effort) + } + + // A task handed to it by hand on a model, as goa delegate does. + rest() + var task proto.GOATask + if err := c.Call(proto.MethodGOADelegate, proto.GOADelegateParams{Session: s.ID, Prompt: "fix the totals", Model: "claude-fable-5-1", Effort: "max"}, &task); err != nil { + t.Fatal(err) + } + if task.Model != "claude-fable-5-1" { + t.Fatalf("the task: %+v", task) + } + kept("a task's model", "--model claude-fable-5-1", "--effort max") + + // The board's Change model, the worker's turn over. + rest() + waitFor(t, "its turn ended on the board", func() bool { + tasks := goaBoardOf(t, c).Tasks + return len(tasks) == 1 && tasks[0].Status == proto.GOATaskTurnEnded + }) + if err := c.Call(proto.MethodGOAOverride, proto.GOAOverrideParams{ID: task.ID, Model: "claude-haiku-5-5"}, nil); err != nil { + t.Fatal(err) + } + kept("the board's Change model", "--model claude-haiku-5-5") + if got := goaBoardOf(t, c).Tasks[0].Status; got != proto.GOATaskTurnEnded { + t.Fatalf("starting again counted as its task's end: %s", got) + } + + // At work it isn't stopped, and says so. + if err := c.Call(proto.MethodSessionState, proto.SessionStateParams{ID: s.ID, State: proto.StateWorking}, nil); err != nil { + t.Fatal(err) + } + n := len(linesOf(starts)) + if err := c.SwitchSettings(proto.SessionSettingsParams{ID: s.ID, Model: "claude-opus-5-5"}); err == nil || !strings.Contains(err.Error(), "turn's end") { + t.Fatalf("switched at work: %v", err) + } + if len(linesOf(starts)) != n { + t.Fatal("stopped at work") + } + + // What this test watches for: the fake saves what is typed, as the real one does. + if err := c.Input(s.ID, "/model typed", false, true); err != nil { + t.Fatal(err) + } + waitFor(t, "the typed /model to reach the file", func() bool { + got, _ := os.ReadFile(settings) + return strings.Contains(string(got), "typed") + }) +} diff --git a/daemon/teamsync.go b/daemon/teamsync.go index 60350810..3f4c14e2 100644 --- a/daemon/teamsync.go +++ b/daemon/teamsync.go @@ -64,6 +64,12 @@ const ( teamSyncEvery = 30 * time.Second teamSyncSettle = 2 * time.Second // after a change, for the next ones teamSyncTime = 2 * time.Minute // a sync's longest + // pullEvery is how long a chat's refresh is answered from the last pull + // instead of the cloud. A chat open asks every 5 seconds, and the side + // panel and the Team page both show it, so two views ask a second or two + // apart: one round trip answers both, and one that follows a sync has + // nothing to ask for at all. + pullEvery = 3 * time.Second // maxTeamFile is the most a file shared holds: a sealed item's size, // less its path and the seal's. maxTeamFile = api.MaxItemSize - 4096 @@ -147,6 +153,14 @@ func (s *Server) syncSpaceWith(team string, push bool) (proto.Space, error) { if sp.Team == "" { return proto.Space{}, fmt.Errorf("spaces: team %s isn't on this host", team) } + if !push { + // A chat's refresh: the sync that just ended took everything, and the + // other view of the chat asked a moment ago. Asking the cloud again + // would be a round trip for what this host already has. + if at, err := s.teamSync.status(team); err == "" && time.Since(at) < pullEvery { + return sp, nil + } + } st, err := s.cloud.store.Get() if err != nil { return sp, err @@ -221,6 +235,9 @@ type teamSyncState struct { RetryAt time.Time `json:"retryAt,omitzero"` // Recheck is the last teamRecheck done. Recheck int `json:"recheck,omitempty"` + // RecheckAt is the place the recheck reached: a pull cut off partway asks + // from it next time, not from the start of the space again. + RecheckAt int64 `json:"recheckAt,omitempty"` } type syncedFile struct { @@ -347,6 +364,13 @@ func (ts *teamSync) pull(ctx context.Context) (err error) { }() after := ts.pullFrom() for { + // Where the recheck has got to: a pull that fails partway, even on the + // reply, keeps the seq it asked from, and the next one carries on from + // there instead of asking for the whole space again. It moves past a + // page only once that page is applied. + if ts.state.Recheck < teamRecheck { + ts.state.RecheckAt = after + } page, err := ts.c.Items(ctx, ts.team, after) if err != nil { return err @@ -409,7 +433,7 @@ func (ts *teamSync) pull(ctx context.Context) (err error) { if ts.state.Retry == 0 { ts.state.RetrySince = time.Time{} } - ts.state.Recheck = teamRecheck + ts.state.Recheck, ts.state.RecheckAt = teamRecheck, 0 return nil } } diff --git a/daemon/teamsync_pull_test.go b/daemon/teamsync_pull_test.go new file mode 100644 index 00000000..48aa667e --- /dev/null +++ b/daemon/teamsync_pull_test.go @@ -0,0 +1,132 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/enowx/enowx/cloud/api" +) + +// readSyncState is what a host's copy of team's space last agreed with the +// cloud on. +func readSyncState(t *testing.T, h teamHost, team string) teamSyncState { + t.Helper() + var st teamSyncState + data, err := os.ReadFile(filepath.Join(h.srv.teamsDir(), team, "sync", "state.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &st); err != nil { + t.Fatal(err) + } + return st +} + +// seedItems puts n items in team's space, from h's device: they don't open +// with the team's key, and a pull passes them by, page after page. +func seedItems(t *testing.T, sc *hookedCloud, team string, h teamHost, n int) { + t.Helper() + ctx := context.Background() + dev := deviceOf(t, h) + for i := 1; i <= n; i++ { + p := api.PutItemParams{Kind: api.ItemFile, Version: 1, Epoch: 1, Sealed: []byte("sealed")} + if _, err := sc.db.PutItem(ctx, team, h.acc, dev, fmt.Sprintf("%016x", i), p); err != nil { + t.Fatal(err) + } + } +} + +// A pull cut off partway carries on from where it stopped, not from the +// start of the space: asking for the whole space again is what made a chat's +// refresh read it all, every 5 seconds, for a billion bytes a start. +func TestAPullCutOffResumesWhereItStopped(t *testing.T) { + sc := newHookedCloud(t) + acme, hs := teamOfHosts(t, sc, []string{"ada"}, []string{""}) + ada := hs[0] + seedItems(t, sc, acme, ada, 201) // two pages: 200, then 1 + + var pages atomic.Int32 + sc.setHook(func(w http.ResponseWriter, r *http.Request, next http.Handler) { + if strings.HasSuffix(r.URL.Path, "/items") && pages.Add(1) == 2 { + http.Error(w, "down", http.StatusBadGateway) + return + } + next.ServeHTTP(w, r) + }) + if _, err := ada.srv.syncSpace(acme); err == nil { + t.Fatal("the sync didn't fail") + } + sc.setHook(nil) + + st := readSyncState(t, ada, acme) + if st.Recheck >= teamRecheck { + t.Fatal("a pull cut off marks the recheck done: the rest is never asked for") + } + if st.RecheckAt == 0 { + t.Fatal("a pull cut off keeps no place to carry on from") + } + + asked := atomic.Int64{} + asked.Store(-1) + sc.setHook(func(w http.ResponseWriter, r *http.Request, next http.Handler) { + if strings.HasSuffix(r.URL.Path, "/items") { + if n, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64); err == nil { + asked.CompareAndSwap(-1, n) + } + } + next.ServeHTTP(w, r) + }) + ada.sync(t, acme) + sc.setHook(nil) + if got := asked.Load(); got != st.RecheckAt { + t.Fatalf("asked from %d, not from %d where it stopped", got, st.RecheckAt) + } +} + +// A chat's two views ask a second or two apart: the second is answered from +// the sync that just ended, not with a read of the whole space again. +func TestAChatRefreshIsAnsweredFromTheLastSync(t *testing.T) { + sc := newHookedCloud(t) + acme, hs := teamOfHosts(t, sc, []string{"ada", "bob"}, []string{"", api.RoleMember}) + ada, bob := hs[0], hs[1] + ada.sync(t, acme) // the team's key + bob.sync(t, acme) + + var reads atomic.Int32 + sc.setHook(func(w http.ResponseWriter, r *http.Request, next http.Handler) { + if strings.HasSuffix(r.URL.Path, "/items") { + reads.Add(1) + } + next.ServeHTTP(w, r) + }) + defer sc.setHook(nil) + // The sync loop's own, after a change, may answer the first refresh: ask again + // past pullEvery until one of them reads the space. + for deadline := time.Now().Add(4 * pullEvery); reads.Load() == 0; { + if time.Now().After(deadline) { + t.Fatal("no refresh read the space") + } + if err := bob.c.RefreshChat(acme); err != nil { + t.Fatal(err) + } + if reads.Load() == 0 { + time.Sleep(pullEvery + 100*time.Millisecond) + } + } + before := reads.Load() + if err := bob.c.RefreshChat(acme); err != nil { + t.Fatal(err) + } + if got := reads.Load(); got != before { + t.Fatalf("a refresh right after one read the space again: %d reads, not %d", got, before) + } +} diff --git a/daemon/teamtrust.go b/daemon/teamtrust.go index e84af08c..6ebc67a2 100644 --- a/daemon/teamtrust.go +++ b/daemon/teamtrust.go @@ -266,10 +266,12 @@ func (ts *teamSync) later(p string, it api.Item, why string) { // seen, or before items to ask again for while it is time to. func (ts *teamSync) pullFrom() int64 { // Once, for a host a version of enowX left past items it never took: every item - // again, those it has passing by, as their versions say. + // again, those it has passing by, as their versions say. A pull cut off + // partway asks from where it got to, not from the start: asking again for + // the pages it already took is what a timed-out first pull made of this. if ts.state.Recheck < teamRecheck { ts.state.Retry, ts.state.RetrySince = 0, time.Time{} - return 0 + return ts.state.RecheckAt } from, retry, since := ts.state.Seq, ts.state.Retry, ts.state.RetrySince if retry <= 0 || retry-1 >= from { diff --git a/daemon/teamtrust_retry_test.go b/daemon/teamtrust_retry_test.go index d6ae6ea0..998ba170 100644 --- a/daemon/teamtrust_retry_test.go +++ b/daemon/teamtrust_retry_test.go @@ -44,6 +44,12 @@ func TestHostPastItemsRecoversOnce(t *testing.T) { if got := ts.pullFrom(); got != 2103 { t.Fatalf("asked from %d, after", got) } + // Cut off partway, it carries on from where it stopped: asking for the whole + // space again is what read it all, every few seconds. + ts.state.Recheck, ts.state.RecheckAt = 0, 869 + if got := ts.pullFrom(); got != 869 { + t.Fatalf("a recheck cut off asked from %d, not from 869", got) + } } // What waits is asked for again, and past the first day still, less often: what was seen diff --git a/daemon/tokens_hosts.go b/daemon/tokens_hosts.go index 7ce3daf7..a56aeacd 100644 --- a/daemon/tokens_hosts.go +++ b/daemon/tokens_hosts.go @@ -25,12 +25,12 @@ func (s *Server) writeHostDays(now time.Time, prices *tokens.Prices) { return } since := now.AddDate(0, 0, -hostDaysKept) - calls := s.tok.store.Calls(since, now.Add(time.Minute)) + rows := s.tok.store.DayRows(since, now.Add(time.Minute), prices) s.tok.store.Forget() - if len(calls) == 0 { + if len(rows) == 0 { return } - days := proto.TokensHostDays{Host: id, Name: s.userdata.Name, Updated: now.UTC().Truncate(time.Second), Rows: tokens.DayRows(calls, prices)} + days := proto.TokensHostDays{Host: id, Name: s.userdata.Name, Updated: now.UTC().Truncate(time.Second), Rows: rows} data, err := json.Marshal(days) if err != nil { return diff --git a/daemon/tokens_layers.go b/daemon/tokens_layers.go index 846efb0c..5c95dd5f 100644 --- a/daemon/tokens_layers.go +++ b/daemon/tokens_layers.go @@ -5,9 +5,11 @@ import ( "fmt" "strconv" "strings" + "sync/atomic" "time" "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/rag" "github.com/enowx/enowx/core/tokens" "github.com/enowx/enowx/proto" ) @@ -120,15 +122,21 @@ func (s *Server) judgeTokens(d proto.Decision) { s.recordOwn(c) } -// ragTokens keeps an index's embedding tokens as RAG's, enowX's own. +// ragCallSeq numbers RAG's calls: an index embeds several batches at once, +// and two of them in the same nanosecond would otherwise be one call kept. +var ragCallSeq atomic.Int64 + +// ragTokens keeps embedding tokens as RAG's, enowX's own: an index's, and +// a prompt's search, which embeds its query every time. func (s *Server) ragTokens(dir string, n int64) { if n <= 0 { return } st := s.ragSettings.Get() now := time.Now() - s.recordOwn(tokens.Call{ID: "rag:" + dir + ":" + strconv.FormatInt(now.UnixNano(), 36), Time: now, Project: dir, - Own: proto.TokensOwnRAG, Provider: st.Embedder, Model: st.Model, Input: n}) + id := "rag:" + dir + ":" + strconv.FormatInt(now.UnixNano(), 36) + ":" + strconv.FormatInt(ragCallSeq.Add(1), 36) + s.recordOwn(tokens.Call{ID: id, Time: now, Project: dir, + Own: proto.TokensOwnRAG, Provider: st.Embedder, Model: rag.EmbedderModel(st), Input: n}) } // tokenGate refuses a new agent session while a budget it falls under is diff --git a/daemon/transcript.go b/daemon/transcript.go index 73b8e8ce..9776113d 100644 --- a/daemon/transcript.go +++ b/daemon/transcript.go @@ -10,6 +10,7 @@ import ( "time" "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/session" "github.com/enowx/enowx/core/transcript" "github.com/enowx/enowx/proto" ) @@ -21,6 +22,10 @@ type transcripts struct { mu sync.Mutex byID map[string]*follower files map[string]string // transcript file → the session following it + // told is the conversation each session's agent said it is on, which it may not have + // written yet. Kept apart from Meta.Conversation, which resumes: an agent can't resume + // a conversation it never wrote. + told map[string]string } type follower struct { @@ -146,7 +151,12 @@ func (t *transcripts) owned(id string) string { func (t *transcripts) forget(id string) { t.mu.Lock() defer t.mu.Unlock() + t.forgetLocked(id) +} + +func (t *transcripts) forgetLocked(id string) { delete(t.byID, id) + delete(t.told, id) for path, owner := range t.files { if owner == id { delete(t.files, path) @@ -154,6 +164,41 @@ func (t *transcripts) forget(id string) { } } +// tell notes the conversation session id's agent says it is on; one it wasn't told before +// lets go of what the session followed, which the chat then shows anew. +func (t *transcripts) tell(id, convo string) { + t.mu.Lock() + defer t.mu.Unlock() + if t.told[id] == convo { + return + } + t.forgetLocked(id) + if t.told == nil { + t.told = map[string]string{} + } + t.told[id] = convo +} + +func (t *transcripts) toldOf(id string) string { + t.mu.Lock() + defer t.mu.Unlock() + return t.told[id] +} + +// conversationStarted takes what an agent's start says of its conversation. Claude Code names +// it, even before writing it. Its teams' preset doesn't count, as a teammate starts too. An +// agent that doesn't say it but says it cleared is on a new one, to be found. +func (s *Server) conversationStarted(sess *session.Session, ev proto.HookEvent) { + meta := sess.Meta() + switch { + case ev.Conversation != "" && meta.Agent == "claude": + s.transcripts.tell(sess.ID(), ev.Conversation) + case ev.Source == "clear": + s.sessions.NewConversation(sess) + s.transcripts.forget(sess.ID()) + } +} + // transcript returns what changed in session id's conversation. func (s *Server) transcript(p proto.TranscriptParams) (proto.Transcript, error) { sess, err := s.sessions.Get(p.ID) @@ -178,7 +223,8 @@ func (s *Server) transcript(p proto.TranscriptParams) (proto.Transcript, error) reset := s.findTranscript(p.ID, info, home, f) || f.opened f.opened = false if f.reader == nil { - return proto.Transcript{Supported: true, Reason: "Nothing yet: the conversation shows here once " + name + " records it."}, nil + // A chat holding what it followed before drops it: that isn't this conversation. + return proto.Transcript{Supported: true, Reset: p.Since > 0, Reason: "Nothing yet: the conversation shows here once " + name + " records it."}, nil } if err := f.reader.Update(); err != nil { return proto.Transcript{}, err @@ -203,6 +249,11 @@ func (s *Server) transcript(p proto.TranscriptParams) (proto.Transcript, error) out := proto.Transcript{Supported: true, Entries: entries, Rev: rev, Reset: reset, Model: f.reader.Model, Mode: f.reader.Mode, Effort: f.reader.Effort, ContextTokens: f.reader.Context, CostUSD: f.reader.CostUSD, BudgetUSD: f.reader.BudgetUSD} + if s.floorOf(info.ID, f.reader.Path()) > 0 { + // Switched: the transcript names the model it left until its next answer. + meta := sess.Meta() + out.Model, out.Effort = meta.Model, meta.Effort + } if out.ContextTokens > 0 { out.ContextWindow = f.reader.Window if out.ContextWindow == 0 { @@ -217,37 +268,121 @@ func (s *Server) transcript(p proto.TranscriptParams) (proto.Transcript, error) return out, nil } -// findTranscript has f follow the newest file of the conversation of session id, looking -// every lookEvery, and says when that is another file than it followed. f.mu is held. +// findTranscript has f follow the file of the conversation of session id, looking every +// lookEvery, and says when that is another file than it followed. f.mu is held. // Finding reads nothing of the file: what only wants its name stays cheap. func (s *Server) findTranscript(id string, info proto.SessionInfo, home string, f *follower) (reset bool) { if f.reader != nil && time.Since(f.checked) <= lookEvery { return false } f.checked = time.Now() + current := "" + if f.reader != nil { + current = f.reader.Path() + } + path := s.ownTranscript(id, info, home, current) + if path == "" || path == current { + return false + } + f.reader = transcript.Open(info.Agent, path) + return true +} + +// ownTranscript is the file of session id's own conversation, "" while it has none, current +// being the one it follows. Agents keep conversations by folder, and an older session's in +// the same folder keeps changing, so a file's being new says nothing of whose it is: +// - in the session's own folder (Preset.SessionDir), the newest is its own; +// - a conversation known by id is that file alone, written or not; +// - otherwise, a conversation that began after the session did and no other session is on. +// +// What it finds is kept as the session's, so that resuming and a restart go on with it. +func (s *Server) ownTranscript(id string, info proto.SessionInfo, home, current string) string { + sess, err := s.sessions.Get(id) + if err != nil { + return "" + } + meta := sess.Meta() since := info.Created - if sess, err := s.sessions.Get(id); err == nil && sess.Meta().ConvoSince.After(since) { - since = sess.Meta().ConvoSince - } - for _, found := range transcript.FindIn(info.Agent, home, info.Dir, s.agentStore(info.Agent, id, false)) { - // Conversations older than the session belong to earlier ones; - // a restored session's changes once its agent continues. - if found.Changed.Before(since.Add(-time.Second)) { - break + if meta.ConvoSince.After(since) { + since = meta.ConvoSince + } + since = since.Add(-time.Second) + store := s.agentStore(info.Agent, id, false) + if stored := transcript.Stored(store); len(stored) > 0 { + if stored[0].Changed.Before(since) { + return "" + } + s.transcripts.claim(id, stored[0].Path, current) + s.keepConversation(sess, stored[0].Path) + return stored[0].Path + } + want := s.transcripts.toldOf(id) + if want == "" { + want = meta.Conversation + } + if want != "" { + if current != "" && transcript.ConversationID(info.Agent, current) == want { + return current + } + for _, found := range transcript.Find(info.Agent, home, info.Dir) { + if transcript.ConversationID(info.Agent, found.Path) == want { + // Its own even if another session follows it: resumed in two, both show it. + s.transcripts.claim(id, found.Path, current) + s.sessions.SetConversation(sess, want) + return found.Path + } + } + return "" + } + if store != "" { + return "" // it keeps them in its own folder, and has none yet + } + others := s.othersConversations(id, info.Agent) + for _, found := range transcript.Find(info.Agent, home, info.Dir) { + if found.Changed.Before(since) { + break // sorted newest first: the rest are older still + } + if others[transcript.ConversationID(info.Agent, found.Path)] { + continue + } + if began, ok := transcript.Started(info.Agent, found.Path); ok && began.Before(since) { + continue // it was going before this session: changing now, it is another's + } + if s.transcripts.claim(id, found.Path, current) { + s.keepConversation(sess, found.Path) + return found.Path + } + } + return "" +} + +// keepConversation records the conversation at path as sess's. +func (s *Server) keepConversation(sess *session.Session, path string) { + if convo := transcript.ConversationID(sess.Meta().Agent, path); convo != "" { + s.sessions.SetConversation(sess, convo) + } +} + +// othersConversations are the conversations other sessions of agent's are on. They outlast a +// restart, which the claims on files don't. +func (s *Server) othersConversations(id, agentID string) map[string]bool { + out := map[string]bool{} + for _, other := range s.sessions.List() { + if other.ID() == id { + continue } - if f.reader != nil && f.reader.Path() == found.Path { - break + meta := other.Meta() + if agent.Base(meta.Agent) != agent.Base(agentID) { + continue } - old := "" - if f.reader != nil { - old = f.reader.Path() + if meta.Conversation != "" { + out[meta.Conversation] = true } - if s.transcripts.claim(id, found.Path, old) { - f.reader, reset = transcript.Open(info.Agent, found.Path), true - break + if told := s.transcripts.toldOf(other.ID()); told != "" { + out[told] = true } } - return reset + return out } // rewind takes session id's agent back to before one of the user's diff --git a/daemon/transcript_test.go b/daemon/transcript_test.go index d9300aed..089db0f6 100644 --- a/daemon/transcript_test.go +++ b/daemon/transcript_test.go @@ -213,7 +213,7 @@ func TestTranscriptPathReadsNothing(t *testing.T) { t.Fatal(err) } file := filepath.Join(folder, "11111111-1111-4111-8111-111111111111.jsonl") - line := `{"type":"user","timestamp":"2026-10-22T06:00:00Z","message":{"role":"user","content":"hello"}}` + "\n" + line := `{"type":"user","timestamp":"` + time.Now().UTC().Format(time.RFC3339) + `","message":{"role":"user","content":"hello"}}` + "\n" if err := os.WriteFile(file, []byte(line), 0o600); err != nil { t.Fatal(err) } @@ -248,3 +248,139 @@ func TestTranscriptPathReadsNothing(t *testing.T) { t.Fatal("asking for the path kept it from being let go") } } + +// claudeConversation writes a conversation begun at began, and changing now. +func claudeConversation(t *testing.T, home, dir, id string, began time.Time, text string) string { + t.Helper() + folder := filepath.Join(home, ".claude", "projects", transcript.ClaudeFolder(dir)) + if err := os.MkdirAll(folder, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(folder, id+".jsonl") + line := `{"type":"user","timestamp":"` + began.UTC().Format(time.RFC3339Nano) + `","message":{"role":"user","content":"` + text + `"}}` + "\n" + if err := os.WriteFile(path, []byte(line), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// A new session in the folder of one long at work shows nothing until it writes its own, +// though the other's file is the newest there: what `chat` showed of GOD of Agents. +func TestNewSessionDoesNotShowAnothersConversation(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + srv := startServer(t) + dir := t.TempDir() + sess, err := srv.sessions.Create(session.Spec{Command: []string{"sleep", "30"}, Dir: dir, Meta: session.Meta{Agent: "claude"}}) + if err != nil { + t.Fatal(err) + } + claudeConversation(t, home, dir, "older", time.Now().Add(-time.Hour), "not yours") + got, err := srv.transcript(proto.TranscriptParams{ID: sess.ID()}) + if err != nil || len(got.Entries) != 0 || !got.Supported { + t.Fatalf("before it wrote: %+v, %v", got, err) + } + if path := srv.transcriptPath(sess.ID()); path != "" { + t.Fatalf("it follows %s", path) + } + if convo := srv.sessionConversation(sess); convo != "" { + t.Fatalf("it would resume %s", convo) + } + own := claudeConversation(t, home, dir, "mine", time.Now(), "yours") + if path := srv.transcriptPath(sess.ID()); path != own { + t.Fatalf("its own: %q", path) + } + if meta := sess.Meta(); meta.Conversation != "mine" { + t.Fatalf("kept %q", meta.Conversation) + } +} + +// Two sessions begun together each stay on their own, also once the claims on files are gone, +// as after a restart; a conversation known by id isn't left for a newer file. +func TestSessionsInOneFolderKeepTheirOwnConversation(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + srv := startServer(t) + dir := t.TempDir() + create := func(convo string) *session.Session { + t.Helper() + sess, err := srv.sessions.Create(session.Spec{Command: []string{"sleep", "30"}, Dir: dir, Meta: session.Meta{Agent: "claude", Conversation: convo}}) + if err != nil { + t.Fatal(err) + } + return sess + } + a, b := create("a"), create("") + fileA := claudeConversation(t, home, dir, "a", time.Now(), "for a") + if got := srv.transcriptPath(a.ID()); got != fileA { + t.Fatalf("a follows %q", got) + } + srv.transcripts.forget(a.ID()) + if got := srv.transcriptPath(b.ID()); got != "" { + t.Fatalf("b took a's: %q", got) + } + fileB := claudeConversation(t, home, dir, "b", time.Now(), "for b") + if got := srv.transcriptPath(b.ID()); got != fileB { + t.Fatalf("b follows %q", got) + } + srv.transcripts.forget(a.ID()) + if got := srv.transcriptPath(a.ID()); got != fileA { + t.Fatalf("a moved to %q", got) + } +} + +// Claude Code naming its conversation settles it before the file exists, and /clear moves the +// chat to the new one, which isn't what a restart resumes until it is written. +func TestClaudeCodeSaysWhichConversationItIsOn(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + srv := startServer(t) + dir := t.TempDir() + sess, err := srv.sessions.Create(session.Spec{Command: []string{"sleep", "30"}, Dir: dir, Meta: session.Meta{Agent: "claude"}}) + if err != nil { + t.Fatal(err) + } + start := func(convo, source string) { + t.Helper() + ev := agentEvent(proto.HookSessionStart, []byte(`{"session_id":"`+convo+`","source":"`+source+`"}`)) + if _, err := srv.sessionHook(proto.SessionHookParams{ID: sess.ID(), Event: ev}); err != nil { + t.Fatal(err) + } + } + start("first", "startup") + claudeConversation(t, home, dir, "someone-elses", time.Now(), "not yours") + if got := srv.transcriptPath(sess.ID()); got != "" { + t.Fatalf("it follows %q", got) + } + first := claudeConversation(t, home, dir, "first", time.Now(), "yours") + if got := srv.transcriptPath(sess.ID()); got != first { + t.Fatalf("it follows %q", got) + } + start("second", "clear") + got, err := srv.transcript(proto.TranscriptParams{ID: sess.ID(), Since: 3}) + if err != nil || !got.Reset || len(got.Entries) != 0 { + t.Fatalf("after /clear: %+v, %v", got, err) + } + if meta := sess.Meta(); meta.Conversation != "first" { + t.Fatalf("it would resume %q, not yet written", meta.Conversation) + } + second := claudeConversation(t, home, dir, "second", time.Now(), "after clear") + if got := srv.transcriptPath(sess.ID()); got != second { + t.Fatalf("it follows %q", got) + } +} + +func TestStartedIsTheFirstRecordsTime(t *testing.T) { + path := filepath.Join(t.TempDir(), "c.jsonl") + lines := `{"type":"last-prompt","sessionId":"c"}` + "\n" + `{"type":"user","timestamp":"2026-10-22T06:00:00Z"}` + "\n" + `{"type":"user","timestamp":"2026-11-01T06:00:00Z"}` + "\n" + if err := os.WriteFile(path, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + got, ok := transcript.Started("claude", path) + if want := time.Date(2026, 10, 22, 6, 0, 0, 0, time.UTC); !ok || !got.Equal(want) { + t.Fatalf("%v, %v", got, ok) + } +} diff --git a/daemon/vault.go b/daemon/vault.go index e7ce80ca..eb199566 100644 --- a/daemon/vault.go +++ b/daemon/vault.go @@ -56,6 +56,9 @@ type vaultLease struct { agentCreds map[string]bool // the credentials those are of agentEnv map[string]string agentArmed bool + // agentID is the agent the lease was made for: another agent's key is laid out + // differently, so a lease isn't used for one (agentkey.go). + agentID string } func (s *Server) vaultDir() string { return filepath.Join(filepath.Dir(s.WorktreeRoot), "vault") } diff --git a/daemon/vault_lease.go b/daemon/vault_lease.go index 562dec9c..9cb2d88d 100644 --- a/daemon/vault_lease.go +++ b/daemon/vault_lease.go @@ -21,15 +21,18 @@ import ( // logins through enowX's helpers, which ask the app at each use. // vaultSessionEnv is the environment new session id (of project; a tab of -// parent; agentID's, to say who asks) gets from the vault: the credentials of names, -// those the app puts in every session, and its helpers. -func (s *Server) vaultSessionEnv(id, project, parent string, names []string, agentID string) ([]string, []string) { +// parent; agentID's, to say who asks; account's, for the key its agent runs on) gets +// from the vault: the credentials of names, those the app puts in every session, and +// its helpers. A tab has its session's, but for the key its agent alone runs on: a tab +// that is such an agent asks for its own, as a new session does. +func (s *Server) vaultSessionEnv(id, project, parent string, names []string, agentID, account string) ([]string, []string) { idx := s.vaultIndex() if !idx.Present { return nil, nil } - l := &vaultLease{env: map[string]string{}} + l := &vaultLease{env: map[string]string{}, agentID: agentID} l.agentVars, l.agentCreds = agentKeyHeld(agentID, idx) + platform := s.agentKeyPlatform(agentID, account) if p := s.vaultLease(parent); p != nil { // A tab has its session's, but for the key its agent alone runs on, unless the // tab is such an agent itself. @@ -41,27 +44,37 @@ func (s *Server) vaultSessionEnv(id, project, parent string, names []string, age } l.secrets = slices.Clone(p.secrets) for k, v := range p.env { + if platform != "" && l.agentVars[k] { + // Its agent's own, kept out of the tab's environment, where the tab's + // other programs and its wrapper shells would read it. + l.takeAgentVar(k, v) + continue + } l.env[k] = v } for k, v := range p.agentEnv { - if l.agentVars[k] { + if platform != "" && l.agentVars[k] { l.takeAgentVar(k, v) } } s.vault.mu.Unlock() - } else if len(names) > 0 || slices.ContainsFunc(idx.Credentials, func(c proto.CredentialInfo) bool { return c.Auto }) { - if s.vault.served() { - a := proto.VaultAsk{Action: proto.VaultSession, Session: id, Names: names} - req := proto.VaultRequest{VaultAsk: a, Project: project, Agent: agentID} - req.Hostname, _ = os.Hostname() - lend, err := s.vaultRequest(req) - s.vaultAudit(a, lend, err) - if err != nil { - s.logger().Warn("vault: a session starts without its credentials", "id", id, "err", err) - } else { - s.vaultTake(id, l, lend) + // A tab that is an agent running on a key of its own, in a session that isn't + // that agent, holds none: it asks for one, as a new session does and with the + // same consent. Its session's other credentials stay as they are. + if platform != "" && len(l.agentEnv) == 0 { + var want []string + for _, n := range names { + if l.agentCreds[n] { + want = append(want, n) + } + } + auto := slices.ContainsFunc(idx.Credentials, func(c proto.CredentialInfo) bool { return c.Auto && c.Platform == platform }) + if len(want) > 0 || auto { + s.askSession(id, project, agentID, l, want) } } + } else if len(names) > 0 || slices.ContainsFunc(idx.Credentials, func(c proto.CredentialInfo) bool { return c.Auto }) { + s.askSession(id, project, agentID, l, names) } s.vaultHelpers(id, l, idx) s.vault.mu.Lock() @@ -77,6 +90,25 @@ func (s *Server) vaultSessionEnv(id, project, parent string, names []string, age return l.environ(), l.names } +// askSession asks the app for the credentials a session (id, of project, its agent +// agentID) starts with, names, and keeps what it lends. A refusal leaves it without +// them, as a session with no app does: its agent says what is missing. +func (s *Server) askSession(id, project, agentID string, l *vaultLease, names []string) { + if !s.vault.served() { + return + } + a := proto.VaultAsk{Action: proto.VaultSession, Session: id, Names: names} + req := proto.VaultRequest{VaultAsk: a, Project: project, Agent: agentID} + req.Hostname, _ = os.Hostname() + lend, err := s.vaultRequest(req) + s.vaultAudit(a, lend, err) + if err != nil { + s.logger().Warn("vault: a session starts without its credentials", "id", id, "err", err) + return + } + s.vaultTake(id, l, lend) +} + // vaultTake keeps what the app lent session id: its variables, its files // written to the session's folder of them. func (s *Server) vaultTake(id string, l *vaultLease, lend proto.VaultLend) { @@ -266,16 +298,17 @@ func (s *Server) vaultLease(id string) *vaultLease { } // vaultEnvFor is the environment session id's program starts with again: -// its lease's, or (after the daemon restarted, or when its lease lacks one of names, an -// app being gone when it was made) its credentials lent again while an app answers. -func (s *Server) vaultEnvFor(id, project string, names []string, agentID string) []string { - if l := s.vaultLease(id); l != nil && (!s.vault.served() || s.leaseHolds(l, names)) { +// its lease's, or (after the daemon restarted, when its lease lacks one of names, an +// app being gone when it was made, or when it was another agent's) its credentials +// lent again while an app answers. +func (s *Server) vaultEnvFor(id, project string, names []string, agentID, account string) []string { + if l := s.vaultLease(id); l != nil && l.agentID == agentID && (!s.vault.served() || s.leaseHolds(l, names)) { s.vault.mu.Lock() l.agentArmed = len(l.agentEnv) > 0 // it starts again, and takes it again s.vault.mu.Unlock() return l.environ() } - env, _ := s.vaultSessionEnv(id, project, "", names, agentID) + env, _ := s.vaultSessionEnv(id, project, "", names, agentID, account) return env } @@ -300,7 +333,7 @@ func (s *Server) vaultAttach(p proto.VaultAttach) ([]string, error) { } l := s.vaultLease(p.Session) if l == nil { - s.vaultSessionEnv(p.Session, sess.Meta().Project, "", nil, sess.Meta().Agent) + s.vaultSessionEnv(p.Session, sess.Meta().Project, "", nil, sess.Meta().Agent, sess.Meta().Account) if l = s.vaultLease(p.Session); l == nil { return nil, errors.New("vault: this host has no vault yet: open Credentials in the app") } diff --git a/docs/agent-api.md b/docs/agent-api.md index 7e593e26..f8ed4a9d 100644 --- a/docs/agent-api.md +++ b/docs/agent-api.md @@ -1,6 +1,6 @@ # Model APIs -A model API of the user's (a company gateway, OpenRouter, LiteLLM, a local server) is a **config** of an agent for the agents that can take one per session: Claude Code, Codex, OpenCode, Gemini CLI, Antigravity, GitHub Copilot, OpenClaude, Aider, Kilo Code, Qwen Code, Goose, Kimi, Continue, Crush. Their own settings are never written; a session started on the config gets the API through its environment and command line ([configs.md](configs.md)). `agentapi.set` refuses them, and a model API set in their files before becomes a config. +A model API of the user's (a company gateway, OpenRouter, LiteLLM, a local server) is a **config** of an agent for the agents that can take one per session: Claude Code, Codex, OpenCode, Gemini CLI, Antigravity, GitHub Copilot, OpenClaude, Aider, Kilo Code, Qwen Code, Goose, Kimi, Continue, Crush, OMP. Their own settings are never written; a session started on the config gets the API through its environment and command line ([configs.md](configs.md)). `agentapi.set` refuses them, and a model API set in their files before becomes a config. The other agents below take one only in their own settings. For them, their page in **Settings → Agents** writes the API's address, key and model into the agent's own settings, the way that agent takes them (`core/agentapi`), so its sessions use it, and keeps the files as they were first, to put them back. Agents do the same from the command line (`enowx-app api`) and over MCP (`agent_api_*`). What follows is how each agent takes an API in its files, which is also what a config gives it per session where it can; for the agents marked *moved*, enowX no longer writes those files: they are where it finds an API set before, to make it a config. [configs.md](configs.md#applied-per-session) has what each gets per session. @@ -26,6 +26,7 @@ What enowX adds is named `enowx`, so it finds it again and leaves the user's own | Continue (*moved*) | OpenAI chat, Anthropic, Gemini | a config of enowX's, `~/.enowx-app/agentapi/continue.yaml`, given to its sessions (`--config`); the user's own untouched. A session's own holds `${{ secrets.ENOWX_CONTINUE_API_KEY }}`, not the key | docs.continue.dev/cli/configuration | | Crush | OpenAI chat, Anthropic | its sessions' `CRUSH_GLOBAL_CONFIG` and `CRUSH_GLOBAL_DATA`, folders of the session's own: `crush.json` with `providers.enowx` (`type`, `base_url`, `api_key: $ENOWX_CRUSH_API_KEY`, `models`), `models.large`, `models.small` | github.com/charmbracelet/crush (README, `schema.json`, `internal/config/load.go`) | | Pi | OpenAI chat, OpenAI Responses, Anthropic, Gemini | `~/.pi/agent/models.json` `providers.enowx`, `defaultProvider`/`defaultModel` of `~/.pi/agent/settings.json` | pi-mono's models docs | +| OMP | OpenAI chat, OpenAI Responses, Anthropic | `models.yml` in its agent folder (`PI_CODING_AGENT_DIR`): `providers.enowx` (`baseUrl`, `api`, `apiKey`, `models`), the model by `--model enowx/` | github.com/can1357/oh-my-pi (`pi-utils/src/dirs.ts`, `pi-coding-agent/src/config/model-registry.ts`, `resolve-config-value.ts`) | The others take no model API from enowX for the reason [configs.md](configs.md#every-agent) gives each; Settings → Agents says it on their page. @@ -46,6 +47,7 @@ An API can carry several models, the first the default, and for each what the ag | Kimi | — | `KIMI_MODEL_CAPABILITIES` `image_in` | `KIMI_MODEL_MAX_CONTEXT_SIZE` | — | — | | Mistral Vibe | `[[models]]` | `supports_images` | `max_context_length` | `max_output_tokens` | per model, `thinking`: off, low, medium, high, max | | Pi | provider `models` | `input` | `contextWindow` | `maxTokens` | per model, `modelThinkingLevels`: off, minimal, low, medium, high, xhigh, max | +| OMP | `models` | `input` | `contextWindow` | `maxTokens` | — | | Continue | `models` by role | `capabilities` `image_input` | `contextLength` | `maxTokens` | — | | GitHub Copilot | — | — | `COPILOT_PROVIDER_MAX_PROMPT_TOKENS` | `COPILOT_PROVIDER_MAX_OUTPUT_TOKENS` | — | | Gemini CLI, Antigravity, OpenClaude | — | — | — | — | — | diff --git a/docs/agents.md b/docs/agents.md index acec1a0b..086b047f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -323,6 +323,16 @@ A session's row and `enowx-app ls` say which agents its agent has running beside The session list, `sessions` and `ls` show each agent session's project, path and model. The model is what the agent writes in its transcript with each answer, looked at every few seconds, so `/model`, a switch, a fallback and a config all show, Claude Code's `/model` at once and others with their next answer. An agent enowX reads no transcript of, or one that hasn't answered yet, reads "unknown model"; a stopped session shows the last it said, marked "(last)". A program started by hand in a shell tab is listed as the shell, with no model. +### What a switch does + +`switch --model/--effort`, the chat's composer, `goa delegate --model/--effort` on a session already there and the board's **Change model** all change that session alone (`session.settings`). Claude Code's `/model` and `/effort` save what they set in `~/.claude/settings.json` as the default for new sessions (2.1.318), and Codex's `/model` keeps its choice too (read in 0.166's program, not run), so enowX never types them for those agents: **the agent stops and starts again on its conversation with `--model`/`--effort`** (`-c` for Codex), which hold for that process, and the user's settings file isn't touched. It takes a few seconds, and the session comes back on those flags each time it starts. + +- **When it is refused, nothing having changed:** while its agent is at work or asking (try at its turn's end), when stopping would lose what it runs or serves, or when it runs on a config, which decides its model (switch its config). `goa delegate` still gives the task then, its board line saying the session kept its own model. A session that isn't running only takes the new values, for when it comes back. +- **The permission mode isn't affected:** Shift+Tab holds for the process, as does `--permission-mode` (2.1.318: only `/config`'s default mode writes `permissions.defaultMode`), so `session.mode` is as it was. +- **Claude Platform** keeps its typed `/model` and `/effort`, which hold for its session alone. +- **Typing `/model` yourself** in a session's terminal or chat is still Claude Code's own command, and saves your default as it says. +- **An older daemon** answers that it can't, and nothing is typed in its place: update it. + ## Skipping permission prompts New session's **Skip permission prompts**, offered for agents other than Claude Code, starts an agent in its mode that asks nothing. Sessions the user doesn't start by hand follow it only where nothing would hear a prompt: an agent with no hooks (OpenCode and most others; every agent on Windows), started as GOD of Agents, its worker, or the chat of a note or a canvas (`SkipUnheard`). Codex tells enowX it is asking, so GOA's workers of it stay as `goa delegate` said (`--yolo`), and GOA answers. `goa start --yolo` is the same default on the command line. diff --git a/docs/chat-view.md b/docs/chat-view.md index d5458428..c56f48e7 100644 --- a/docs/chat-view.md +++ b/docs/chat-view.md @@ -16,7 +16,13 @@ The daemon reads the transcript the agent records on its own host (`session.tran ## Which file belongs to a session -A session's transcript is the newest file for its folder that changed after the session was created. A new file (after `/clear`, say) takes over; a file another session follows is left to it, so two Claude Code sessions in one folder each keep their own. +A session shows its own conversation and nothing else, however many sessions share its folder. An older session's file keeps changing, so a file being new says nothing of whose it is. + +- **In its own folder:** an agent given one (`Preset.SessionDir`: Claude Platform, OMP) keeps its conversations there, and the newest is the session's. With none there yet, it shows nothing. +- **Known by id:** Claude Code names its conversation as it starts (`SessionStart`'s `session_id`), even before writing it, so the session shows nothing until that file appears, and a new id (`/clear`, `/resume`) moves it there. A session resumed, imported or found earlier has its id too (`Meta.Conversation`), which outlasts a restart. +- **Otherwise (Codex, Claude Agent Teams, no hooks):** a conversation whose first record is after the session's start (`transcript.Started`; a file with no time, by when it changed) and that no other session is on. Once found, it stays: `/clear` moves it only where the agent's hook says so (`source` `clear`), so on Windows, which has no hooks, the chat stays on the old conversation after `/clear`. + +Resume, fork, transfer, handoffs and the model shown all go by this choice. ## What the chat shows @@ -44,15 +50,15 @@ Its sheet (Settings → Agents, its chat, the new session dialog; `ui/platformvi ## Changing the agent's settings -The composer shows the agent's model, permission mode and effort, as the transcript and the screen say them, and changes them the way the agent's user would (`core/agent/settings.go`): +The composer shows the agent's model, permission mode and effort, as the transcript and the screen say them, and changes them as this table says (`core/agent/settings.go`). The model and effort change for that session alone (`session.settings`, [agents.md](agents.md#what-a-switch-does)): Claude Platform is changed the same way as Claude Code: `/model ` (Opus 5.5, its default, Fable 5.1, Sonnet 5.5, Haiku 5.5, Opus 5, Sonnet 5, Haiku 4.5), Shift+Tab until the screen says the mode (ask first, accept edits, plan, bypass permissions, each named on its status line), `/effort `. All read back from its record's `meta`. Its chat offers no going back to edit a message. | Setting | Claude Code | Codex (checked on 0.164) | |---|---|---| -| Model | types `/model `, with the names Claude Code records: Default, Opus 5.5, Fable 5.1, Sonnet 5.5, Haiku 5.5, Haiku 4.5, Sonnet 5, Opus 5, Fable 5, Opus 4.8 (the first ten of its `/model` menu on 2.1.308). Read back from `message.model`. | types `/model `: GPT-6-Luna, GPT-5.6-Terra, GPT-5.6-Luna (its `/model` menu). Read back from `turn_context`. | +| Model | starts again on its conversation with `--model `, never typing `/model`, with the names Claude Code records: Default, Opus 5.5, Fable 5.1, Sonnet 5.5, Haiku 5.5, Haiku 4.5, Sonnet 5, Opus 5, Fable 5, Opus 4.8 (the first ten of its `/model` menu on 2.1.308). Read back from `message.model`. | starts again on its conversation with `-c model=`: GPT-6-Luna, GPT-5.6-Terra, GPT-5.6-Luna (its `/model` menu). Read back from `turn_context`. | | Mode | the daemon lands on the mode (`session.mode`) and the call says if it didn't. Shift+Tab goes auto, manual, accept edits, plan, bypass permissions, in that order (checked on 2.1.318), with no other way to change mode as it runs, so plan is on the way from any of the first three to bypass. A key is pressed where the way doesn't go through plan, one at a time, each waiting for the screen; none if the screen doesn't say a mode. Otherwise Claude Code starts again on its conversation in that mode (`--permission-mode`, or bypass), which is refused while its agent is at work or asking, or if stopping would lose what it runs or serves; bypass is what it comes back in after. Read back from the screen, else from `permissionMode`. | Shift+Tab switches Default and Plan; the status line says "Plan mode" and nothing in Default. | -| Effort | types `/effort `: low, medium, high, xhigh, max. Read back from `effort` on assistant records. | types `/model --reasoning=`: low, medium, high, xhigh, max. Read back from `turn_context`. | +| Effort | starts again on its conversation with `--effort `: low, medium, high, xhigh, max. Read back from `effort` on assistant records. | starts again on its conversation with `-c model_reasoning_effort=`: low, medium, high, xhigh, max. Read back from `turn_context`. | Other agents show no settings. @@ -62,7 +68,7 @@ A session keeps its model, effort and permission mode through restarts, wakes, r When the model its agent says differs from what the session started on, the session says so: `(started on …)` beside the model in the app's row and in `enowx-app ls`, `modelStarted` in its info, and a line in the daemon's log. Claude Code's transcript tells the two causes apart: `/model` records its answer, so that becomes the session's model, and a model it moved to by itself never goes on its command line, so a restart returns to the one it was given. Other agents' transcripts read the same either way (Codex's `turn_context`, OMP's `model_change`), so what they last said stands, with the same words. -Claude Code saves what `/model` and `/effort` set as its default, in a file all its sessions read, and enowX can't stop that. Sessions already there aren't changed by it, as they say their own at each start. New ones take what Settings, New sessions start on says for that agent; left at "The agent's own", they take whatever that file says by then. A task's routing picks what a new session starts on: a session that already exists keeps its own, unless GOA or the user asks for a model by hand. +Claude Code saves what `/model` and `/effort` set as its default, in a file all its sessions read, when the user types them; enowX doesn't. Sessions already there aren't changed by it, as they say their own at each start. New ones take what Settings, New sessions start on says for that agent; left at "The agent's own", they take whatever that file says by then. A task's routing picks what a new session starts on: a session that already exists keeps its own, unless GOA or the user asks for a model by hand. ## Editing a message diff --git a/docs/claude-platform.md b/docs/claude-platform.md index 6c55d8fc..dc957a85 100644 --- a/docs/claude-platform.md +++ b/docs/claude-platform.md @@ -47,7 +47,7 @@ The key lives only in the vault, as a credential of platform `anthropic` (`Prese **At a session's start** the daemon picks the credential from the host's index (the one scoped to the session's project, else the platform's default, else the first; `vault.AgentKey`), and asks the app for it with the session's other credentials (`vault.request`, action `session`), the same lending as `creds env` ([vault.md](vault.md)): the app answers at once on a host the credential may be used on, and otherwise asks the user: **Allow once**, **Always on HOST**, **Refuse**, naming Claude Platform and the host. The daemon keeps the value in memory for the session, masked wherever a session's screen, conversation or traffic is read, and the session keeps only the credential's name. -**Tabs and other programs of the session** (Android runs, the Run menu's, a worktree's setup) get its other credentials as ever, but not this one, nor its name, so a restart doesn't lend it to them; a tab that is itself Claude Platform takes its own. Any credential of platform `anthropic` in such a session counts, a named or an automatic one too. Running `claude-platform` by hand in the session's shell after the agent left finds no key waiting: the session's next start has one. +**Tabs and other programs of the session** (Android runs, the Run menu's, a worktree's setup) get its other credentials as ever, but not this one, nor its name, so a restart doesn't lend it to them; a tab that is itself Claude Platform takes its own, asking the app for it as a new session does when the session it is in isn't Claude Platform. Any credential of platform `anthropic` in such a session counts, a named or an automatic one too. Running `claude-platform` by hand in the session's shell after the agent left finds no key waiting: the session's next start has one. **Hibernating and waking** use what the daemon holds. **After a daemon restart** nothing is held and no app is connected yet, so a session that has a key waits, stopped, and continues by itself once an app serves the vault; waking one before that says it needs the app, rather than start with no key. A credential added since a session started is picked up the next time it starts. diff --git a/docs/cli.md b/docs/cli.md index 24eb5db0..535678a3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,7 +120,7 @@ A session controls sessions and settings of any host the app reaches, with the s | `close SESSION --yes` | Ends it, its tabs and what runs in them (`session.kill`); `kill` is the same. A tab of a session needs no `--yes`. | | `chat SESSION [--last N]` | Its last messages, as its agent recorded them, a line that looks like a secret taken out. | | `answer SESSION [allow\|always\|deny\|N]` | What its agent waits for (a tool it asks to use and what it would do, or its questions) and the answers it takes (`session.ask`), or answers it with the agent's own keys (`session.answer`), as the chat does. | -| `switch SESSION [--model M] [--effort E] [--mode M] [--account A]` | Changes its model and effort (the agent's own command typed in), its permission mode (`session.mode`, landing there without going through plan mode: [chat-view.md](chat-view.md)) or its account (`account.switch`), as the chat does. | +| `switch SESSION [--model M] [--effort E] [--mode M] [--account A]` | Changes its model and effort for that session alone (`session.settings`: Claude Code and Codex start again on their conversation, refused while at work: [agents.md](agents.md#what-a-switch-does)), its permission mode (`session.mode`, landing there without going through plan mode: [chat-view.md](chat-view.md)) or its account (`account.switch`), as the chat does. | | `changes SESSION [status \| diff [--files] \| commit -m MSG \| push --yes \| pr [--title T] [--body B] --yes \| merge --yes \| discard [--delete-branch] --yes] [--base B]` | Its branch against its base and what it changed; committing, pushing, opening a pull request, merging, discarding its worktree ([review.md](review.md)). | | `settings [show \| set KEY VALUE \| env [set NAME VALUE \| unset NAME] \| app [set KEY VALUE]]` | The host's settings (`max-agents`, `hibernate-after`, `auto-hibernate`, `shell`), the variables every session gets (masked), and the app's own (New session's agent, folder, skipping permissions and time budget, the terminals' font and size, theme, mode and accent, desktop banners, port cards, stopping agents on quit, the editor) through `app.settings`, which the app attached answers. | | `project settings [P] [--copy A,B] [--setup SCRIPT]` | A project's `.enowx.toml` ([project-settings.md](project-settings.md)): what new worktrees get, and their setup script (`project.config`, `project.config.save`). Its comments don't survive a change. | diff --git a/docs/configs.md b/docs/configs.md index 039b55eb..211d7baf 100644 --- a/docs/configs.md +++ b/docs/configs.md @@ -42,6 +42,7 @@ A model API's key is read from the vault as the session starts (the app holding | Kimi | `KIMI_MODEL_NAME`, `KIMI_MODEL_API_KEY`, `KIMI_MODEL_BASE_URL`, `KIMI_MODEL_PROVIDER_TYPE`, `KIMI_MODEL_MAX_CONTEXT_SIZE`, `KIMI_MODEL_CAPABILITIES` | | Continue | `--config` to a file of the session's own, with `apiKey: ${{ secrets.ENOWX_CONTINUE_API_KEY }}`, the key in that variable | | Crush | `CRUSH_GLOBAL_CONFIG` and `CRUSH_GLOBAL_DATA` to folders of the session's own, its `crush.json` with `api_key: $ENOWX_CRUSH_API_KEY`, the key in that variable. Those sessions don't read the user's own `crush.json` | +| OMP | `PI_CODING_AGENT_DIR` to an agent folder of the session's own, whose `models.yml` holds the config's provider `enowx` (`baseUrl`, `api`, `models` with their context, output and images) and names the key as `apiKey: ENOWX_OMP_API_KEY`, the key in that variable; `--model enowx/`; `OMP_SKIP_SETUP=1`, as a fresh folder would run its setup wizard. Those sessions don't read the user's `~/.omp/agent`: not its models, settings or sign-in | The other agents [agent-api.md](agent-api.md) lists (Droid, Mistral Vibe, Pi) take a model API only in their own settings: their page in Settings → Agents still sets one there, backed up first, as before. @@ -61,6 +62,7 @@ The other agents [agent-api.md](agent-api.md) lists (Droid, Mistral Vibe, Pi) ta - **Claude Code and Codex:** tested with stand-in programs that record the variables and arguments each session gets (`daemon/configs_test.go`), and run for real on a throwaway daemon against a stand-in API (`daemon/configs_real_test.go`, `ENOWX_CONFIGS_REAL_HOME`): the session and its task show the model, the key is in no file or command line, and at a limit the API answered, each went on with the next config. Claude Code shows the model the API answered with; Codex the one it asked for. - **OpenCode:** run for real, `OPENCODE_REAL=1 go test ./core/agentapi -run OpenCode`. A session config's `OPENCODE_CONFIG` made it call the config's API, with the key from its environment and the model named; the key wasn't written to the file. - **Gemini CLI:** checked against its source. `GEMINI_CLI_AUTH_OVERRIDE`, `GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`, `GEMINI_MODEL` and `--model` are what it reads. +- **OMP:** run for real on this Mac (18.15.0) on a throwaway daemon, its config's key lent from the vault: a session on the config answered on its model, `enowx/cb/deepseek-v4.1-flash`, with the status line showing the config's 1M context, from a `models.yml` of the session's own. What its sessions get, the key in no file or argument, is tested in `core/agentapi/session_routes_test.go`. - **Aider:** checked against its docs. `OPENAI_API_BASE`, `OPENAI_API_KEY`, `--model openai/` and `--model-metadata-file` (a JSON keyed by `provider/model`). - **Kilo Code, Qwen Code, Goose, Kimi, Continue, Crush, GitHub Copilot's providers file:** read from their docs and source, none run: tested only for what a session gets, the key in no file or argument (`core/agentapi/session_routes_test.go`). Each needs a real run before it's relied on. - **Antigravity:** `--model` is in `agy --help`. Whether it takes a model named by your API wasn't run. @@ -92,7 +94,7 @@ What each agent's CLI or docs offered on 2026-11-01 or 02, beside what enowX dri | Qoder CLI China | an API key | docs | A model is added only in its /model screen: its docs say not to edit the settings by hand. No other account: QODERCN_CONFIG_DIR moves its folder, but its docs name no file for the sign-in. | | ZCode | no config | docs | Its docs describe a desktop app, with no command line to give a config to. | | Pi | another account, a model API in its own settings, an API key | docs | Another account is read from its docs, not signed in: auth.json in PI_CODING_AGENT_DIR is the account's. It has no sign-in command: use /login in a session on the config. | -| OMP | an API key | run | It takes a provider's address, but only for models named in models.yml, which enowX doesn't write yet: a model of your own wasn't found when run. | +| OMP | a model API per session, an API key | run | A config's sessions read only the config's models, not your own ~/.omp settings, skills and sign-in: a folder of the session's own is the only way to give one another models.yml. | | Prime Agent | an API key | docs | Only a models.json takes a model API, at a place its docs don't name. | | Antigravity | a model API per session, an API key | help | No separate account is documented. | | Aider | a model API per session, an API key | docs | No account: it has no sign-in to separate, only API keys. | @@ -119,6 +121,6 @@ What each agent's CLI or docs offered on 2026-11-01 or 02, beside what enowX dri | CodeBuddy | an API key | run | CODEBUDDY_BASE_URL and its key do work per session, but run here it wrote the key into its own logs, so enowX doesn't send it that way. No other account: CODEBUDDY_CONFIG_DIR moves its folder, but its docs name no file or keychain item for the sign-in. | | Jcode | an API key | docs | JCODE_OPENAI_API_BASE and OPENAI_API_KEY exist, but its sessions share one server: they don't reach a server already running, and one a session starts keeps the key after it ends. Its own sign-in also wins over OPENAI_API_KEY. No other account: JCODE_HOME isn't in its README, and that server wouldn't follow it. | -Run for real on this Mac: OpenCode and CodeBuddy against a local stand-in API. CodeBuddy took its address and key from the environment and answered through it, but wrote the key into its own logs, so enowX doesn't send it that way. OMP refused a model outside its `models.yml`. +Run for real on this Mac: OpenCode and CodeBuddy against a local stand-in API. CodeBuddy took its address and key from the environment and answered through it, but wrote the key into its own logs, so enowX doesn't send it that way. OMP refused a model outside its `models.yml`; with a `models.yml` of the session's own (OMP 18.15.0) it answered on the config's model, its status line showing the config's 1M context. Not checked: x.ai's and DeepSeek Harness's own sites couldn't be read, so Grok and DeepSeek Harness rest on third parties' docs. GitHub's docs imply an empty `providers.json` leaves its variables working, without saying so. Goose's docs name `GOOSE_PROVIDER__*`, which weren't found in the source of its OpenAI and Anthropic providers, so a session gets those providers' own variables too. Codex's `--profile` now layers a file, which may not select `-c profiles.*`. These wait for those agents to be installed here. diff --git a/docs/decide.md b/docs/decide.md index 4213c5f0..638deaff 100644 --- a/docs/decide.md +++ b/docs/decide.md @@ -13,11 +13,12 @@ Every use is **on** until the user turns it off (Settings → Decisions → Uses | Trimming long output | a hook **Trim its long output** before a tool runs | trimmed (with the lines kept), keep | the agent reads only the lines kept, with a note naming the file that holds the whole output; the exit code is unchanged | | Stopping loops | three of an agent's last eight commands failed, two of them the same | looping, progress | looping: presses Escape to stop the agent's turn, and asks the user whether to let it go on | | A model per prompt | asked: New session's **Suggest a model for this prompt**, `enowx decide route`, MCP `decide_route` | easy, medium, hard | suggests the agent's model and effort to match (below); nothing changes until the user or the agent asking takes it | -| RAG: context or not | a prompt starts in a project whose RAG adds context ([rag.md](rag.md)) | relevant (with the parts kept), none | the prompt gets only the parts that bear on it, or none | | RAG: memories to keep | a session ends in a project whose RAG offers memories | decision (with the decision, distilled), nothing | decision: the offer's text is the decision; nothing: no offer | | Team chat urgency | a team member's message comes in while the chat isn't open | urgent, normal, quiet | urgent: the sound, card and banner even when the chat is set to mentions or off; quiet: only counted unread. A message naming or answering the user is never kept quiet | | Flaky or real test failures | a check fails (the side panel's Checks) | flaky, real | shown on the failed check with why; a flaky one offers **Run again** | +**A prompt's project search is never judged.** Every prompt of a session whose project is indexed gets the parts the search found, the four best, up to 6000 characters ([rag.md](rag.md)). There is no use to turn off and no verdict to drop them: the judge used to choose which parts bore on a prompt (`context`), and that use is retired — older settings that name it are read and ignored. + **Permission prompts on an agent's screen** are answered too, however the agent started, also Claude Code's that still show in `bypassPermissions` and a Claude Code run by hand in a shell tab (`daemon/prompt_answer.go`). enowX reads the screen, and asks the judge with the whole command and the prompt's warning; at its usual confidence minimum it presses allow or deny. What it isn't sure of, and what can't be undone, spends money, publishes or touches secrets, goes to the owner at once, saying how long the prompt gives; only a sure refusal is acted on there. Each is in `decide log`. A prompt that gave a time and left the screen unanswered is logged as `prompt gone`, and GOA is told; the agent learns from its own refusal. In a shell tab the answer's keys are known only for Claude Code's numbered menu; another agent's prompt there goes to the owner. **With no decision model set up, or none answering,** GOD of Agents answers its own workers' prompts: it is told at once, even mid-turn, with the command and the warning. What can't be undone, money, publishing and secrets still go to the owner. A session outside GOA, or with GOA off, goes to the owner as ever. @@ -53,7 +54,7 @@ The judge says how hard a prompt is; enowX maps that to the agent's own models ( An agent set to another model API (Settings → Model APIs) gets that API's small model for easy prompts and its main model otherwise. With no answer, or the judge not sure enough, the agent keeps its own model and effort. -The answer is `{agent, difficulty, model, effort, why, switch, config}`: `switch` is what to type in a running session of the agent for that model and effort (its `/model`, `/effort`), and a new session starts on them (`enowx spawn --model M --effort E`, New session's **Use it**). A running session's model never changes on its own. `config` is one of the agent's model API configs ([configs.md](configs.md)), another provider, when it does an easy or medium prompt for less than the agent's own model, by the price table of token tracking ([tokens.md](tokens.md)): its small model for an easy prompt, its main one for a medium one. A hard prompt keeps the agent's own model; with prices unknown, nothing is switched. GOD of Agents starts a worker with that config ([goa.md](goa.md)). +The answer is `{agent, difficulty, model, effort, why, switch, config}`: `switch` is what to type in a running session of the agent for that model and effort, none for an agent whose commands would save them for all its sessions (`enowx switch` changes those, [agents.md](agents.md#what-a-switch-does)), and a new session starts on them (`enowx spawn --model M --effort E`, New session's **Use it**). A running session's model never changes on its own. `config` is one of the agent's model API configs ([configs.md](configs.md)), another provider, when it does an easy or medium prompt for less than the agent's own model, by the price table of token tracking ([tokens.md](tokens.md)): its small model for an easy prompt, its main one for a medium one. A hard prompt keeps the agent's own model; with prices unknown, nothing is switched. GOD of Agents starts a worker with that config ([goa.md](goa.md)). ## Decision models first diff --git a/docs/goa.md b/docs/goa.md index 57a066ce..4e4ff3a1 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -13,7 +13,7 @@ GOD of Agents (GOA) is one session the user drives that controls every enowX ses - The side panel stays, its **GOA** group first: - **Sessions**: every session on every host the app reaches, its state and the task GOA gave it; a session that waits on the user says so. Clicking one opens it in a tab of the strip: its terminal, live, on any host the app reaches, GOA and its conversation staying as they are; a stopped one is brought back first. Closing the tab closes the view alone, and the session runs on. Its details button shows its task. Dragging one into a chat or terminal puts a pointer at it where the prompt is typed, `[session ID: name, agent on host]`, to edit and send; the guide tells agents what it means. - - **Tasks**: the board, newest first: each task's session and host, the model and effort its prompt runs on and why, its status and result, what it asks. **Change model** sets another model and effort: switched in its session at once (its `/model`, `/effort`) and kept on the board as chosen by the user; GOA is told. Below, what a watched GOA did itself, files to revert (the user confirms). + - **Tasks**: the board, newest first: each task's session and host, the model and effort its prompt runs on and why, its status and result, what it asks. **Change model** sets another model and effort: switched in its session alone, by starting it again on its conversation ([agents.md](agents.md#what-a-switch-does)), refused while its agent is at work, and kept on the board as chosen by the user; GOA is told. Below, what a watched GOA did itself, files to revert (the user confirms). GOA's mode shows the host the app shows; the app tells that host of GOA's workers on other hosts as they change (`goa.worker`), as only it sees them. With the app closed, GOA keeps controlling its own host; other hosts wait until the app is back, whose first look at each host tells GOA's host what its workers there did meanwhile (and every look after, for a change it missed). @@ -77,7 +77,7 @@ Checked with Codex itself, started as GOA: enowX's tools reached it over MCP as `enowx goa delegate` (MCP `goa_delegate`, `goa.delegate`) hands a task over: 1. **Its session**: one named (`--session`, `--host`); with `--project` and no `--branch`, the one the project already has (below); else a new one of `--agent` in `--project` on `--host`, the project added there first, `--branch` a worktree of its own. A new one is GOA's worker (`SessionInfo.GOAWorker`). A session whose agent stopped (hibernated or ended) is brought back on its conversation, and the prompt typed once it shows again and goes quiet. If it can't come back, delegate fails and nothing goes on the board. Everything goes through `host.call`, on any host the app reaches, so control's checks, the user's yes and the log apply ([cli.md](cli.md#control)). -2. **Its model and effort**: the judge's `decide route` ([decide.md](decide.md)) for the prompt: easy to a cheap model on low effort, hard to the strongest on high, and a step cheaper when a token budget is at 80% or more ([tokens.md](tokens.md)). A new session starts on them (`SessionCreateParams.Model`, `Effort`); a running one gets its `/model` and `/effort` typed first. `--model`, `--effort`, `--config` (another provider, [configs.md](configs.md)) choose by hand; `--no-route` keeps the agent's own. The choice and why are printed, kept on the board, and shown in Tasks, where the user can change them. +2. **Its model and effort**: the judge's `decide route` ([decide.md](decide.md)) for the prompt: easy to a cheap model on low effort, hard to the strongest on high, and a step cheaper when a token budget is at 80% or more ([tokens.md](tokens.md)). A new session starts on them (`SessionCreateParams.Model`, `Effort`); a running one named by hand is switched first, for that session alone ([agents.md](agents.md#what-a-switch-does)). `--model`, `--effort`, `--config` (another provider, [configs.md](configs.md)) choose by hand; `--no-route` keeps the agent's own. The choice and why are printed, kept on the board, and shown in Tasks, where the user can change them. 3. **The board** (`goa.board`, kept in `goa-board.json` beside the daemon's settings, the last 500): the task, its session and host, routing, status, result. **GOA is told, never polls.** The board follows each worker: this host's through its session updates (its agent's `turn.done` hook, or its screen going quiet), other hosts' through the app. A turn's end sets a task `turn-ended`, with what its agent said in `said`: it may be mid-work, so it is no result, and only `goa task --status done|failed` settles it, or its agent ending. When a task's session finishes a turn, asks something, fails or ends, the board changes and a line starting `[enowX GOA]` is typed into GOA's conversation once it is idle: the result (the worker's last message, else its screen's end), what it asks and whose it is to answer. GOA then checks the work (`chat`, `changes`) and settles the task (`goa task ID --status done --result …`). diff --git a/docs/performance.md b/docs/performance.md index e267d871..4358fd6d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -47,7 +47,7 @@ Per thing, on this branch: | What | App, private dirty | Daemon, private dirty | |---|---:|---:| | An idle session | about 0.05 MB (no terminal until it shows) | 0.55 MB | -| A session's full scrollback (10 MiB of its text) | about 10 to 13 MB while its terminal is attached, 0 otherwise | about 13 MB, outside Go, for as long as the session lasts | +| A session's full scrollback | about 10 to 13 MB while its terminal is attached, 0 otherwise | about 2 to 3 MB: the daemon keeps 2 MiB (`session.Scrollback`), outside Go, for as long as the session lasts | | A small canvas (3 designs, 20 KB each) | 2.6 MB once made | none measurable | Before lazy terminals, a full scrollback cost the app the same 10 to 13 MB for every session on the connected host, shown or not. @@ -59,7 +59,7 @@ Same use each time: 3 agents with a stand-in program and a 30 MB conversation ea | Area | Who holds it | v0.6.2 | This branch | Left, and what would move it | |---|---|---:|---:|---| | Terminals | app, native | 10 to 13 per session attached, all of them | Those shown, and the 3 last shown | A smaller scrollback, or fewer kept | -| Terminals | daemon, native | About 13 per full scrollback | The same | **The main cost left in the daemon:** a smaller scrollback | +| Terminals | daemon, native | About 13 per full scrollback | About 2: the scrollback is 2 MiB (`session.Scrollback`) | A smaller scrollback still, but it is no longer the floor with many sessions | | A canvas's layers | app, Go heap | 100 to 150 at 2,000 elements (`widget.Running`, `layerRow`) | About 10: the first 200, the rest on asking | - | | Conversations | daemon, Go heap | About 28 per 30 MB of conversation shown, or of **any agent at work** | Only those a chat shows, for 2 minutes after | - | | Canvas pictures | app, Go heap | Up to 16 per picture, all kept for the open canvas | 64 at most | By construction and a unit test. Not measured in a real run: no picture was kept in these runs, as `canvas.render` didn't return one under Xvfb | @@ -86,9 +86,14 @@ Each row is a commit's effect, measured with the scenario above on builds of the | Open canvas asks without frames | `4bee637` | | | | App's CPU on an idle canvas page, 5 small canvases: 59.3% → 14.2% | | A chat polls only while on screen | `abcf5fe` | | | | Conversations held in the daemon, 4 minutes after 3 chats left the screen: 2 → 0, and with that the daemon at 3 min: 145 → 99 | | This branch, by `scripts/profile-scenario.sh` | | 101 | 168 | 99 | Daemon: Go heap 12 MB, of which conversations none | +| Scrollback 10 → 2 MiB (`session.Scrollback`) | this branch | | | | Daemon, 5 full scrollbacks: 65 → 10 MB, about 2 a session. **Not re-measured on the Mac:** the daemon there wasn't restarted | +| A cut-off team pull carries on where it stopped, and a chat's refresh is answered from the last completed sync | this branch | | | | **Measured on the Mac before it:** `cloudclient.(*Client).Items` allocated 31.65 GB of 109.5 GB, 100% of `Client.call`'s allocs, through `teamSync.pull` (37.67 GB, with 6.01 GB in `seal.OpenItem`), from a re-check that timed out at `?after=869` and was asked for again from seq 0 every 5 s | +| Tokens: only the last 2 days held as calls, older days summed | this branch | | | | **Measured on the Mac before it:** `tokens.(*Store).loadLocked` held 45.6 MB of the heap for 126,000 calls, while 7 days of summaries need 333 rows | The daemon's 137 in one row and 169 in the next is noise of the kind above, from when a collection last ran: 62 MB had just been returned in the first. The last row's app has its canvas open with 2,000-element designs, its web view in the process, and software drawing's buffers. +The last three rows are the Mac's work, and only the scrollback one has a number from a run: the other two are **estimates**, from the profile the owner sent rather than from a scenario here. What they free is the allocation itself (the repeated whole-space reads) and the ~50 MB the token store held, so a daemon holding both should drop by about 60 MB of footprint, less whatever the cloud's one catch-up read still needs. Not measured on a build of this branch. + **Idle CPU**, % of a core, the two builds running at the same moment, under software drawing: | | v0.6.2 | This branch | @@ -110,6 +115,8 @@ The daemon's 137 in one row and 169 in the next is noise of the kind above, from - **An open canvas woke the window several times a second** to ask its page two things, and each asking and each answer drew a frame. - **A chat polled for as long as its session stayed in chat mode**, on screen or not, every 700 ms each, which also kept its conversation in the daemon. - **The app listed every process of the machine** to learn its own RSS. +- **A team's pull that timed out asked for the whole space again, every 5 seconds.** The one-time re-check of a host's space (`teamRecheck`) only marked itself done after the last page, so a pull cut off at `?after=869` after 3m27s restarted from seq 0 at each chat refresh (`ui/teamchat.go` asks every 5 s): `Items` was 100% of the daemon's 1,200 MB allocated in 100 calls on the Mac. It now records the place it reached (`RecheckAt`) and carries on from there, and a chat's refresh within 3 s of a completed sync asks the cloud for nothing. +- **The token store held a year of calls in memory**, read on the first scan: 45.6 MB for 126,000 calls, when a week of summaries needs 333 rows. Only the last two days are held as calls now; older days are summed the first time a summary needs them (`hotDays`). The stand-in agents here don't print, so what agents at work cost isn't in these runs: the owner's 6-hour heap (median 377 MB, high 816) wasn't reproduced, only its likely causes, and the fixture's canvases are one case of them. @@ -128,7 +135,10 @@ The stand-in agents here don't print, so what agents at work cost isn't in these | Lazy loading of pages | **Built where it costs.** Pages are drawn, not kept, so walking them moved the heap by little, other than Canvas. What was loaded regardless of being shown was the terminals, now lazy. Package start-up is a few MB, not worth changing | | Unloading what is out of sight | **Built:** terminals after 3 minutes beyond the 3 last shown; the Canvas page's web view and pictures 2 minutes after leaving. **Not built:** one web view at a time. The Browser tool's and file tabs' web views stay per session until the session closes, and weren't measured | | Image LRU with a size limit | **Built:** canvas pictures, 64 MB. Team chat images already had a limit | -| Scrollback on disk | **App: built in effect**, as its terminals only exist when shown. **Daemon: not built**, see trade-offs | +| Scrollback on disk | **App: built in effect**, as its terminals only exist when shown. **Daemon: the scrollback is 2 MiB** (`session.Scrollback`), measured at 65 → 10 MB for 5 full ones; keeping the rest on disk is still not built, see trade-offs | +| A smaller scrollback (`session.Scrollback`) | **Built:** 10 → 2 MiB. A terminal keeps a fifth as much history, so a long build's log is cut sooner; an agent's chat isn't affected, being read from its transcript. **Not re-measured on the Mac**, as its daemon wasn't restarted | +| Fewer whole-space reads (a team's re-check, a chat's refresh) | **Built:** a pull carries on from where it stopped (`RecheckAt`), and a chat's refresh within 3 s of a completed sync asks the cloud for nothing. `sync.conflicts`'s own size and polling were done earlier, in `83f15e89` | +| A token store that doesn't hold its history | **Built:** the last 2 days as calls, older days summed into rows; a call's ID remembered 40 days across days. Every split, day row, budget and session badge is exact | | Events over polling | **Built for what polled for nothing:** the plan, watches, ports, the app's own usage, frames for looks, canvas asking. Left: the daemon's look at each session's screen (a session's activity every 150 ms at most, its prompts every 3 s, its model every 5 s), for which programs give no event, and and the daemon's whole CPU with 20 idle sessions is now 2.7% of a core on Linux | | One process sampler | **Built:** what only follows processes reuses the last look under 10 s old, a plan looks once, and the app looks only at itself. Left: `goa_watch` reads every process's environment per GOD of Agents session every 5 s | | Incremental transcripts | **Reading was already incremental.** The bug was reading at all, now fixed. Left: a chat's poll walks all entries of its conversation, harmless next to the rest. **Large message buffers aren't bounded yet**: a frame up to 256 MiB is read whole. Canvas pages and pictures need that, so a bound means streaming them | @@ -144,7 +154,7 @@ By private dirty on Linux, which stands in for footprint. **App: around 100 MB at rest with what it held still kept, and under it fresh.** Fresh it holds 56; the heaviest use above, 168 with a canvas of 2,000-element designs open, three terminals kept, two of them with full scrollback, a web view in the process, and software drawing's buffers (about 15 MB of the heap and much of its garbage). Six minutes after leaving that page, at rest: **117**, with 67 in the Go heap's mapping and 26 of anonymous memory, mostly the terminals kept. About 13 of the heap is software drawing's, which a GPU doesn't need, so a Mac may well be under 100 there, though that is an estimate. Keeping one terminal rather than three takes off up to 26 more. -**Daemon: under 100 MB until scrollback passes it, and only a smaller scrollback gets it there with many sessions.** 99 in the use above, 65 of it those 5 full scrollbacks. Fresh 16; each idle session adds 0.55; each full scrollback about 13. At 30 full sessions that is about 16 + 390, whatever else is done. That is the evidence of a floor at today's scrollback, and the reason for the first trade-off. +**Daemon: under 100 MB at 2 MiB of scrollback, and it took three things to get many sessions there.** 99 in the use above, 65 of it those 5 full scrollbacks at the old 10 MiB. Fresh 16; each idle session adds 0.55; each full scrollback was about 13 and is now about 2, so 30 full sessions are about 16 + 60 rather than 16 + 390. The other two were what the Mac showed at 26 sessions: a team's pull reading the whole space again every 5 seconds, and the token store holding a year of calls. Together they were about 70 MB of the Mac's 267 MB footprint, so all three together should carry a many-session daemon under 100 MB there; the scrollback figure is measured, the other two are estimates from that profile. ### Trade-offs for the owner @@ -152,10 +162,9 @@ These change behaviour or carry risk, so they aren't built. | Step | Saves | Consequence | |---|---|---| -| **Scrollback of 10 → 2 MiB** (`session.Scrollback`) | Measured in the daemon: 5 full sessions took 65 MB at 10 MiB and 10 MB at 2 MiB, so 11 MB per full session. 30 full sessions: from about 406 to about 76 MB | **The one with the most effect, and the only one that gets a many-session daemon under 100 MB.** A terminal keeps about a fifth as much history, so a long build's log is cut. Agents' chats aren't affected, being read from their transcripts | -| Older scrollback kept on disk and read on scrolling up | The same, without losing history | Needs the terminal library to page rows in, which it doesn't offer; or replaying on attach, which costs the time and memory while attached. A larger piece of work | +| Older scrollback kept on disk and read on scrolling up | What the smaller scrollback gave up, and without losing history | Needs the terminal library to page rows in, which it doesn't offer; or replaying on attach, which costs the time and memory while attached. A larger piece of work | | Keep 1 terminal instead of 3 (`panesKept`) | Up to 26 MB in the app | Going back to a recent session takes attaching: a "Connecting…" flash and the time to replay its scrollback | -| Free an idle or hibernated session's terminal | Its 13 MB or so | Needs saving and bringing back its screen exactly, which restarts do only approximately. Risk of a session looking wrong | +| Free an idle or hibernated session's terminal | Its 2 MB or so | Needs saving and bringing back its screen exactly, which restarts do only approximately. Risk of a session looking wrong | | A hard `GOMEMLIMIT` such as 256 MB | Bounds Go's part | Past it, up to half the CPU goes on GC; a large canvas or a burst would feel slow. Not recommended above the plain limit until the Mac's numbers ask for it | | A separate `enowxd` for desktops' daemon | 27 MB of RSS measured here, none of footprint | The app ships two binaries, or one more file to update. Only worth it if RSS itself is the target | | Streaming canvas pages and pictures | Bounds what a 256 MiB frame takes | A protocol change, kept compatible | diff --git a/docs/rag.md b/docs/rag.md index 7edd6c5c..48851b87 100644 --- a/docs/rag.md +++ b/docs/rag.md @@ -59,11 +59,19 @@ The side panel's **Code** group has an **Index** tool for the selected session's - **Reindex**, and progress (`rag.progress`) while indexing runs; - a search box. Results show file:line, the symbol and the matching part; clicking one opens the file at those lines, marked; - the project's memories, which can be added, changed and deleted; -- **Give prompts the parts that bear on them**: at the start of a prompt, the four best results, up to 6000 characters, are added as context. This needs the start-of-prompt hook (Claude Code, Codex). With the judge's use **RAG: context or not** on, only those it says bear on the prompt are added ([decide.md](decide.md)). - **Offer to keep decisions as memories**: when a session ends, a card offers the agent's last answer to edit and save (`rag.offer`). With the judge's use **RAG: memories to keep** on, it offers only an answer holding a decision, written as that decision ([decide.md](decide.md)). - **Turn off and delete the index**, which asks first. -The feature `rag` is on by default, and agreeing to index a project turns on both switches above when neither was set. +The feature `rag` is on by default, and agreeing to index a project turns on the memory offer when it wasn't set. + +## Every prompt gets the project's parts + +A prompt is not searched by choice. In a session whose project is indexed, the start-of-prompt hook searches the project with the prompt and adds the four best results, up to 6000 characters, to what the agent is given. Nothing turns that off: not a switch in the Index tool, not the judge. The only thing that keeps a search from running is consent — a project not turned on, or not agreed to for the embedder in use, sends nothing, and nothing is embedded without that agreement. + +- **The query is embedded** on every prompt, like an index's chunks: that is one call to the embedder per prompt. +- **It is bounded**: past 3 seconds the prompt goes on without the index rather than wait (a search measures p90 300 ms). +- **A failed embedder doesn't cost the prompt**: the search falls back to the index's words alone, in the host's own store, and the parts say so (`found by words: the embedder failed`). The failure is logged and shown in Settings → RAG and in the Index tool until a search works again (`RAGStatus.ContextError`). +- **Agents with a start-of-prompt hook**: Claude Code, Claude Agent Teams, Codex, and Claude Platform (the built-in agent, through `core/platformagent/hookbridge`). Any other agent can't be given the parts: enowX has no moment of the prompt to answer, so a session of one of those gets nothing from the index unless its agent searches with `rag search` itself. ## Staying fresh diff --git a/docs/tokens.md b/docs/tokens.md index 707c3a19..7e85ec44 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -67,9 +67,10 @@ What "input" means differs between agents (some count cached tokens in it, some - **Kept by each host's daemon** in `~/.enowx-app/tokens/`, a JSON-lines file a day, **deduplicated by the call's ID** (the agent's message, response or step ID), so records read again count once. An agent that writes one call over several records has **each field's largest kept**, not the first: Claude Code writes a record per content block, and its output grows over them (measured on one Mac's 117,651 messages, November 2026: 72.7% span several records, 28.8% differ between them, nearly all in output; keeping the first lost 40% of the output). A stored call that a later record grows gets a corrected line in its day's file. **One call counts once wherever it appears**: a resumed or copied conversation's call seen in another file or on another day goes to the call already kept. The IDs' hashes and days are read from the day files once per daemon run, not on every scan. Files are read from where the last scan left off (`tokens/scan.json`); SQLite stores from the last row. - **When**: every 20 seconds while tracking is on, and right after a session's turn ends. - **Kept a year**; older days are rolled up into a day's totals per agent, model, project and config (`rollups.json`). +- **Only the last two days are held in memory** as calls (`hotDays`): an older day is read from its file when a summary needs it and summed by everything a summary splits by, a few dozen rows rather than thousands of calls. A call's ID is remembered for 40 days (`whereDays`), so one kept again on another day (a resumed conversation's) counts once. A year of call files cost the daemon about 50 MB of heap before this; now what is held doesn't grow with the history. - **Recount**: when the way of counting changes (`tokens.RecountVersion`), the first scan of a new build with tracking on rebuilds the day files from the agents' records, only reading them. It copies the store aside first (`tokens/before-recount-/`, the day files and `rollups.json`), keeps each call's day and session, leaves enowX's own use, metered and screen calls as they are, and doesn't touch rolled-up days. Calls whose records are gone keep their numbers, their days marked approximate (`recount.json`), and the page says so. The version is written last, so it runs once; cut short, it runs again from the same copy; a failure is logged and tried on the daemon's next start. - **Backfill**: turning tracking on (or **Read back now**, `tokens.backfill`) reads every record back as far as the settings say (30 days by default, up to a year). -- **enowX's own use, apart**: the judge's tokens (each decision's, `core/decide`), RAG's embedder's (each index), never in the agents' totals or budgets. The judge's agent runs leave no records (`--no-session-persistence`, folders `enowx-judge-*` skipped). Canvas and notes agents are sessions, counted as any. +- **enowX's own use, apart**: the judge's tokens (each decision's, `core/decide`), RAG's embedder's (each index, and every prompt's search, which embeds its query — one call a prompt), never in the agents' totals or budgets. The judge's agent runs leave no records (`--no-session-persistence`, folders `enowx-judge-*` skipped). Canvas and notes agents are sessions, counted as any. ## Costs diff --git a/proto/decide.go b/proto/decide.go index a1f000c9..ed4c74b8 100644 --- a/proto/decide.go +++ b/proto/decide.go @@ -29,15 +29,17 @@ const ( DecideTrim = "trim" // a long command output: what the agent needs of it DecideLoop = "loop" // an agent's last steps: looping, or getting on DecideRoute = "route" // a prompt: how hard, so a model and an effort to match - DecideContext = "context" // a project's search results: which bear on a prompt - DecideMemory = "memory" // what an agent said last: a decision worth keeping - DecideChat = "chat" // a team chat message: urgent, normal or quiet - DecideBypass = "bypass" // a prompt of a session in bypass, answered by policy (docs/decide.md); no judge's use - DecideFlaky = "flaky" // a failed check's output: flaky or a real failure + // DecideContext is no longer a use: a prompt's project search always + // adds what it found (docs/rag.md). Kept as the protocol knew it. + DecideContext = "context" + DecideMemory = "memory" // what an agent said last: a decision worth keeping + DecideChat = "chat" // a team chat message: urgent, normal or quiet + DecideBypass = "bypass" // a prompt of a session in bypass, answered by policy (docs/decide.md); no judge's use + DecideFlaky = "flaky" // a failed check's output: flaky or a real failure ) // DecideUses are the uses, in the order they're offered. -var DecideUses = []string{DecideState, DecideApprove, DecideTrim, DecideLoop, DecideRoute, DecideContext, DecideMemory, DecideChat, DecideFlaky} +var DecideUses = []string{DecideState, DecideApprove, DecideTrim, DecideLoop, DecideRoute, DecideMemory, DecideChat, DecideFlaky} // Verdicts. const ( @@ -301,8 +303,9 @@ type RouteChoice struct { // by the judge yet). Config string `json:"config,omitempty"` // Switch is what to type in a running session of the agent for this - // model and effort (its /model, /effort); a new session takes them as - // SessionCreateParams.Model and Effort. + // model and effort, none for an agent whose commands would save them for + // all its sessions (MethodSessionSettings changes those); a new session + // takes them as SessionCreateParams.Model and Effort. Switch []string `json:"switch,omitempty"` Decision string `json:"decision,omitempty"` // the decision's ID in the log Error string `json:"error,omitempty"` diff --git a/proto/hooks.go b/proto/hooks.go index bd1841cc..f0076a9e 100644 --- a/proto/hooks.go +++ b/proto/hooks.go @@ -169,6 +169,9 @@ type HookEvent struct { // Source is why its agent's session started (HookSessionStart): startup, // resume, clear or compact, as its agent says. Source string `json:"source,omitempty"` + // Conversation is the conversation its agent is on, by the id it resumes it with, from an + // agent whose hook says (Claude Code's session_id). + Conversation string `json:"conversation,omitempty"` // Tool is the tool's name, Command its command line or what it was // given, Failed that it failed. Tool string `json:"tool,omitempty"` diff --git a/proto/rag.go b/proto/rag.go index da6c645d..9b2411dd 100644 --- a/proto/rag.go +++ b/proto/rag.go @@ -76,8 +76,10 @@ type RAGProject struct { Consent string `json:"consent,omitempty"` // the embedder agreed to, "provider host" Exclude []string `json:"exclude,omitempty"` MaxFiles int `json:"maxFiles,omitempty"` // 0: 20000 - Context bool `json:"context,omitempty"` // add results to a prompt (start-of-prompt hook) - Offer bool `json:"offer,omitempty"` // offer a session's decisions as memories + // Context is no longer read: a prompt always gets the parts that bear on + // it, where its agent has a start-of-prompt hook (docs/rag.md). + Context bool `json:"context,omitempty"` + Offer bool `json:"offer,omitempty"` // offer a session's decisions as memories // ServerURL, ServerCred and ServerProject are no longer used. ServerURL string `json:"serverUrl,omitempty"` ServerCred string `json:"serverCred,omitempty"` @@ -115,6 +117,9 @@ type RAGStatus struct { Skipped int `json:"skipped,omitempty"` Tokens int64 `json:"tokens,omitempty"` Error string `json:"error,omitempty"` + // ContextError is why the last prompt's search failed: the embedder's + // error, with what the prompt got instead. + ContextError string `json:"contextError,omitempty"` } // RAGPlan is what indexing a project would send: to which embedder (its @@ -147,6 +152,9 @@ type RAGHit struct { Score float64 `json:"score"` Source string `json:"source"` Memory string `json:"memory,omitempty"` // a memory's ID + // Words: found by its words alone, the embedder having failed (a + // search falls back to this rather than give nothing). + Words bool `json:"words,omitempty"` } // RAGRememberParams keeps Text as a memory of the project (or session's). diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index 9a10509d..f0dd5a37 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -1358,6 +1358,7 @@ field HookEvent.backgroundTasks int field HookEvent.branch string field HookEvent.command string field HookEvent.commit string +field HookEvent.conversation string field HookEvent.dir string field HookEvent.event string field HookEvent.exit int @@ -2068,6 +2069,7 @@ field RAGHit.snippet string field RAGHit.source string field RAGHit.start int field RAGHit.symbol string +field RAGHit.words bool field RAGKey.value string field RAGKey.which string field RAGMemory.by string @@ -2110,6 +2112,7 @@ field RAGSettings.serverUrl string field RAGSettings.store string field RAGStatus.added int field RAGStatus.chunks int +field RAGStatus.contextError string field RAGStatus.embedder string field RAGStatus.error string field RAGStatus.files int @@ -2357,6 +2360,9 @@ field SessionRules.hook bool field SessionRules.version string field SessionRules.via string field SessionScreen.text string +field SessionSettingsParams.effort string +field SessionSettingsParams.id string +field SessionSettingsParams.model string field SessionStateParams.id string field SessionStateParams.state string field SessionUsage.id string @@ -3375,6 +3381,7 @@ method session.rename method session.resize method session.rewind method session.screen +method session.settings method session.state method session.stopBackground method session.takeover.answer diff --git a/proto/transcript.go b/proto/transcript.go index 5ab42555..569dad84 100644 --- a/proto/transcript.go +++ b/proto/transcript.go @@ -63,6 +63,21 @@ type SessionModeParams struct { Mode string `json:"mode"` } +// MethodSessionSettings changes a session's model and effort, as its user would, and +// returns once its agent is on them: SessionSettingsParams. An agent whose own /model and +// /effort save what they set as the default for new sessions, in a file all its sessions +// read, is never given them: the session starts again on its conversation, with its +// command line's flags. Others are told the way they take them. +const MethodSessionSettings = "session.settings" + +// SessionSettingsParams change a session's model, its effort, or both; "" keeps one, +// and "default" asks for the agent's own. +type SessionSettingsParams struct { + ID string `json:"id"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` +} + // MethodSessionRewind takes a session's agent back to before one of the // user's messages, as its user would, so that a message sent next takes // its place: SessionRewindParams. It answers once the agent waits for that diff --git a/ui/chat.go b/ui/chat.go index c6f04cb5..da01d276 100644 --- a/ui/chat.go +++ b/ui/chat.go @@ -157,21 +157,25 @@ func (a *App) chatViewSending(c *ui.Context, s proto.SessionInfo, shown func() b ch.View(c, opts) } -// setAgentSetting asks session s's agent for a setting, as its preset -// says: a command typed in for the model and effort, the daemon cycling -// the agent's mode key for the mode. +// setAgentSetting asks session s's daemon for a setting of its agent: the model and effort +// for that session alone (session.settings), the mode by cycling its mode key. func (a *App) setAgentSetting(s proto.SessionInfo, setting, id string) { - set := agent.SettingsOf(s.Agent) + cl := a.client + if cl == nil { + return + } switch setting { - case "model": - a.sendToSession(s.ID, fmt.Sprintf(set.ModelCommand, id)) - case "effort": - a.sendToSession(s.ID, fmt.Sprintf(set.EffortCommand, id)) - case "mode": - cl := a.client - if cl == nil { - return + case "model", "effort": + p := proto.SessionSettingsParams{ID: s.ID, Model: id} + if setting == "effort" { + p = proto.SessionSettingsParams{ID: s.ID, Effort: id} } + go func() { + if err := cl.SwitchSettings(p); err != nil { + a.onMain(func() { a.status = client.Explain(err) }) + } + }() + case "mode": go func() { if err := cl.SwitchMode(s.ID, id); err != nil { a.onMain(func() { a.status = err.Error() }) diff --git a/ui/decide.go b/ui/decide.go index eb406687..cdcf8ff9 100644 --- a/ui/decide.go +++ b/ui/decide.go @@ -65,7 +65,8 @@ var useInfo = map[string][2]string{ proto.DecideTrim: {"Trimming long output", "A test's, build's or install's long output reaches the agent cut to what matters, saving its context; the whole stays in a file. Runs built in, for test, build and install commands."}, proto.DecideLoop: {"Stopping loops", "When an agent's commands keep failing alike, the judge reads its last steps; a loop stops its turn and asks you how to go on, before more tokens go."}, proto.DecideRoute: {"A model per prompt", "The judge says how hard a prompt is, and enowX suggests the agent's model and effort to match: the cheapest for easy ones. New session offers it; agents ask with decide route. A running session's model never changes on its own."}, - proto.DecideContext: {"RAG: context or not", "Before a prompt gets a project's search results (RAG's Context), the judge keeps only those that bear on it, or none."}, + // Retired: kept so a log's older lines read, never offered or asked. + proto.DecideContext: {"RAG: context or not (retired)", "No longer a use: every prompt's project search adds what it found (docs/rag.md)."}, proto.DecideMemory: {"RAG: memories to keep", "When a session ends, its last answer is offered as a memory only when the judge finds a decision in it, written as that decision."}, proto.DecideChat: {"Team chat urgency", "The judge reads a team message: urgent ones sound and show a banner even when the chat is set to mentions or off; quiet ones only count as unread. A message naming you is never kept quiet."}, proto.DecideFlaky: {"Flaky or real test failures", "When a check fails, the judge reads its output: flaky (timing, network, order) or real, shown on the check with why; a flaky one offers Run again."}, @@ -79,7 +80,7 @@ var useShort = map[string]string{ proto.DecideTrim: "A test's or build's long output reaches the agent cut to what matters.", proto.DecideLoop: "When an agent's commands keep failing alike, a loop stops its turn.", proto.DecideRoute: "How hard a prompt is, to suggest a model and effort.", - proto.DecideContext: "Keeps only the search results that bear on the prompt.", + proto.DecideContext: "Retired: a prompt's search always adds what it found.", proto.DecideMemory: "Offers a memory only when the last answer holds a decision.", proto.DecideChat: "Urgent messages sound even when the chat is set to mentions.", proto.DecideFlaky: "Reads a failed check: flaky offers Run again.", diff --git a/ui/rag_panel.go b/ui/rag_panel.go index cba04560..e5464153 100644 --- a/ui/rag_panel.go +++ b/ui/rag_panel.go @@ -157,11 +157,10 @@ func (a *App) ragConsentDialog(c *ui.Context, p *ragPanelState) { p.plan = nil set := p.st.Settings set.On, set.Consent, set.Exclude = true, plan.Host, splitList(p.exclude) - if !set.Context && !set.Offer { - // First turned on: prompts get the parts bearing on them, and - // decisions are offered as memories (each can be turned off). - set.Context, set.Offer = true, true - } + // First turned on: decisions are offered as memories (it can be + // turned off). Prompts get the parts bearing on them whatever + // this says: that isn't a switch. + set.Offer = true a.setRAGProject(p, set, true) } }) @@ -258,14 +257,17 @@ func (a *App) ragOn(c *ui.Context, s proto.SessionInfo, p *ragPanelState) { } }) } + if st.ContextError != "" { + widget.Notice(c, icon.Alert, theme.Warning, "The last prompt's search failed", st.ContextError+". Prompts got what the index's words found instead, marked as such.", nil) + } a.ragMemories(c, p) - widget.Field(c, "Agents here", func() { + widget.Field(c, "Memories", func() { set := st.Settings - if switchRowKey(c, "ctx", &set.Context, "Give prompts the parts that bear on them", "Up to four results, added as the prompt starts (agents with hooks).") || - switchRowKey(c, "offer", &set.Offer, "Offer to keep decisions as memories", "As a session ends, what its agent said last, to edit and keep.") { + if switchRowKey(c, "offer", &set.Offer, "Offer to keep decisions as memories", "As a session ends, what its agent said last, to edit and keep.") { a.setRAGProject(p, set, false) } }) + widget.CardNote(c, "Every prompt gets the four parts of the project that bear on it, up to 6000 characters, as it starts: agents with a start-of-prompt hook only (Claude Code, Codex, Claude Platform).", 3) if widget.DangerButton(c, "Turn off and delete the index").Disabled(p.busy).Clicked() { a.confirm("Turn off project search?", "The index of “"+textfmt.LastPathPart(p.dir)+"” on "+a.host.Name+" is deleted and agents here stop finding the project by meaning. Its memories stay.", "Turn off", func() { set := p.st.Settings diff --git a/ui/rag_projects.go b/ui/rag_projects.go index 11626c40..76a119ce 100644 --- a/ui/rag_projects.go +++ b/ui/rag_projects.go @@ -178,6 +178,9 @@ func (a *App) ragProjectOn(c *ui.Context, st *ragSettingsState, p proto.ProjectI case s.Error != "": widget.StatusLine(c, theme.Error, false, "The last index failed") widget.CardNote(c, s.Error, 3) + case s.ContextError != "": + widget.StatusLine(c, theme.Warning, false, "The last prompt's search failed") + widget.CardNote(c, s.ContextError, 3) case changed: widget.StatusLine(c, theme.Warning, false, "The embedder changed to "+s.Embedder) default: @@ -277,11 +280,10 @@ func (a *App) ragConsentSheet(c *ui.Context, st *ragSettingsState) { open = false set := r.status[dir].Settings set.On, set.Consent, set.Exclude = true, plan.Host, splitList(r.planEx) - if !set.Context && !set.Offer { - // First turned on: prompts get the parts bearing on them, and - // decisions are offered as memories (each can be turned off). - set.Context, set.Offer = true, true - } + // First turned on: decisions are offered as memories (it can be + // turned off). Prompts get the parts bearing on them whatever this + // says: that isn't a switch. + set.Offer = true a.setRAGProjectFor(st, dir, set, true) } }) @@ -323,13 +325,16 @@ func (a *App) ragDetailsSheet(c *ui.Context, st *ragSettingsState) { if s.Error != "" { widget.Notice(c, icon.Alert, theme.Error, "The last index failed", s.Error, nil) } + if s.ContextError != "" { + widget.Notice(c, icon.Alert, theme.Warning, "The last prompt's search failed", s.ContextError+". Prompts got what the index's words found instead, marked as such.", nil) + } set := s.Settings - if switchRowKey(c, "ctx", &set.Context, "Add results to a prompt", "At the start of each prompt, the parts that bear on it.") || - switchRowKey(c, "offer", &set.Offer, "Offer decisions as memories", "When an answer settles something: “Keep this as a memory of the project?”") { + if switchRowKey(c, "offer", &set.Offer, "Offer decisions as memories", "When an answer settles something: “Keep this as a memory of the project?”") { s.Settings = set r.status[dir] = s a.setRAGProjectFor(st, dir, set, false) } + widget.CardNote(c, "Every prompt gets the four parts that bear on it, up to 6000 characters, as it starts (agents with a start-of-prompt hook).", 3) widget.Field(c, "Leave out", func() { widget.Input(c, &r.exclude, "testdata/").FillWidth().Font(theme.MonoFont) }) diff --git a/version/version.go b/version/version.go index abdcad49..ffa3c01f 100644 --- a/version/version.go +++ b/version/version.go @@ -3,4 +3,4 @@ package version // String is the release version. The app replaces daemons of any other. -const String = "0.6.2" +const String = "0.7.0"